From 6e6861b329ccf8a697032429c16ab985e0f6a629 Mon Sep 17 00:00:00 2001 From: "aditya.chandel" <8075870+adityachandelgit@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:23:23 -0600 Subject: [PATCH] Kobo Phase 1: Enable Book Transfer/Sync --- Dockerfile | 7 +- README.md | 13 +- .../booklore/config/LoggingFilter.java | 59 ++++++ .../security/DualJwtAuthenticationFilter.java | 3 +- .../config/security/KoboAuthFilter.java | 103 ++++++++++ .../config/security/SecurityConfig.java | 14 ++ .../config/security/SecurityUtil.java | 5 + .../booklore/controller/KoboController.java | 162 +++++++++++++++ .../controller/KoboSettingsController.java | 39 ++++ .../booklore/exception/ApiError.java | 3 +- .../BookEntityToKoboSnapshotBookMapper.java | 15 ++ .../mapper/KoboReadingStateMapper.java | 53 +++++ .../custom/BookLoreUserTransformer.java | 1 + .../booklore/model/dto/BookLoreUser.java | 1 + .../booklore/model/dto/BookloreSyncToken.java | 16 ++ .../booklore/model/dto/KoboSyncSettings.java | 12 ++ .../booklore/model/dto/UserCreateRequest.java | 1 + .../model/dto/kobo/BookEntitlement.java | 56 ++++++ .../dto/kobo/BookEntitlementContainer.java | 21 ++ .../model/dto/kobo/ChangedEntitlement.java | 18 ++ .../booklore/model/dto/kobo/Entitlement.java | 4 + .../model/dto/kobo/KoboAuthentication.java | 21 ++ .../model/dto/kobo/KoboBookMetadata.java | 159 +++++++++++++++ .../booklore/model/dto/kobo/KoboHeaders.java | 11 ++ .../model/dto/kobo/KoboReadingState.java | 77 ++++++++ .../dto/kobo/KoboReadingStateWrapper.java | 16 ++ .../model/dto/kobo/KoboResources.java | 18 ++ .../model/dto/kobo/KoboTestResponse.java | 26 +++ .../model/dto/kobo/NewEntitlement.java | 19 ++ .../model/dto/request/ShelfCreateRequest.java | 2 + .../model/dto/request/UserUpdateRequest.java | 1 + .../kobo/KoboReadingStateResponse.java | 32 +++ .../entity/KoboDeletedBookProgressEntity.java | 26 +++ .../entity/KoboLibrarySnapshotEntity.java | 29 +++ .../model/entity/KoboReadingStateEntity.java | 44 +++++ .../model/entity/KoboSnapshotBookEntity.java | 29 +++ .../model/entity/KoboUserSettingsEntity.java | 27 +++ .../model/entity/UserPermissionsEntity.java | 3 + .../booklore/model/enums/KoboReadStatus.java | 17 ++ .../booklore/model/enums/PermissionType.java | 1 + .../booklore/model/enums/ShelfType.java | 16 ++ .../booklore/repository/BookRepository.java | 1 + .../BookShelfMappingRepository.java | 2 - .../KoboDeletedBookProgressRepository.java | 18 ++ .../KoboLibrarySnapshotRepository.java | 16 ++ .../KoboReadingStateRepository.java | 12 ++ .../KoboSnapshotBookRepository.java | 77 ++++++++ .../KoboUserSettingsRepository.java | 15 ++ .../booklore/repository/ShelfRepository.java | 3 +- .../service/KoboEntitlementService.java | 187 ++++++++++++++++++ .../service/KoboLibrarySnapshotService.java | 133 +++++++++++++ .../service/KoboReadingStateService.java | 70 +++++++ .../booklore/service/KoboSettingsService.java | 97 +++++++++ .../booklore/service/ShelfService.java | 18 +- .../service/kobo/KoboDeviceAuthService.java | 50 +++++ .../kobo/KoboInitializationService.java | 50 +++++ .../service/kobo/KoboLibrarySyncService.java | 137 +++++++++++++ .../service/kobo/KoboResourcesComponent.java | 184 +++++++++++++++++ .../service/kobo/KoboServerProxy.java | 160 +++++++++++++++ .../service/kobo/KoboThumbnailService.java | 38 ++++ .../service/user/UserProvisioningService.java | 19 +- .../booklore/service/user/UserService.java | 1 + .../booklore/util/RequestUtils.java | 20 ++ .../booklore/util/UserPermissionUtils.java | 1 + .../util/kobo/BookloreSyncTokenGenerator.java | 56 ++++++ .../booklore/util/kobo/KoboUrlBuilder.java | 64 ++++++ .../src/main/resources/application.yaml | 3 + .../db/migration/V48__Create_kobo_tables.sql | 56 ++++++ .../authentication-settings.component.ts | 3 +- .../dashboard-scroller.component.scss | 30 +-- .../layout-menu/app.menu.component.ts | 20 +- .../layout-menu/app.menuitem.component.html | 4 +- .../metadata-viewer.component.html | 2 +- .../device-settings-component.html | 5 + .../device-settings-component.ts | 8 +- .../kobo-sync-settings-component.html | 62 ++++++ .../kobo-sync-settings-component.scss} | 0 .../kobo-sync-settings-component.ts | 95 +++++++++ .../device-settings-component/kobo.service.ts | 24 +++ .../koreader-settings-component.html | 128 ++++++++++++ .../koreader-settings-component.scss | 0 .../koreader-settings-component.ts | 48 ++++- .../koreader.service.ts} | 2 +- .../koreader-settings-component.html | 113 ----------- .../src/app/settings/settings.component.html | 16 +- .../create-user-dialog.component.html | 5 + .../create-user-dialog.component.ts | 1 + .../user-management.component.html | 17 +- .../settings/user-management/user.service.ts | 1 + .../assets/layout/styles/layout/_menu.scss | 2 +- nginx.conf | 10 +- start.sh | 18 ++ 92 files changed, 3084 insertions(+), 198 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/LoggingFilter.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/config/security/KoboAuthFilter.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboSettingsController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookEntityToKoboSnapshotBookMapper.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mapper/KoboReadingStateMapper.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookloreSyncToken.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/KoboSyncSettings.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlement.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlementContainer.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/ChangedEntitlement.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/Entitlement.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboAuthentication.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboBookMetadata.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboHeaders.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboReadingState.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboReadingStateWrapper.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboResources.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboTestResponse.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/NewEntitlement.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/kobo/KoboReadingStateResponse.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboDeletedBookProgressEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboLibrarySnapshotEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboReadingStateEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboSnapshotBookEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboUserSettingsEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/enums/KoboReadStatus.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ShelfType.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboDeletedBookProgressRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboLibrarySnapshotRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboReadingStateRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboSnapshotBookRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboUserSettingsRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/KoboEntitlementService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/KoboLibrarySnapshotService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/KoboReadingStateService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/KoboSettingsService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboDeviceAuthService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboInitializationService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySyncService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboResourcesComponent.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboServerProxy.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboThumbnailService.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/util/RequestUtils.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/util/kobo/BookloreSyncTokenGenerator.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/util/kobo/KoboUrlBuilder.java create mode 100644 booklore-api/src/main/resources/db/migration/V48__Create_kobo_tables.sql create mode 100644 booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.html rename booklore-ui/src/app/settings/{koreader-settings-component/koreader-settings-component.scss => device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.scss} (100%) create mode 100644 booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.ts create mode 100644 booklore-ui/src/app/settings/device-settings-component/kobo.service.ts create mode 100644 booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.html create mode 100644 booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.scss rename booklore-ui/src/app/settings/{ => device-settings-component}/koreader-settings-component/koreader-settings-component.ts (67%) rename booklore-ui/src/app/{koreader-service.ts => settings/device-settings-component/koreader.service.ts} (94%) delete mode 100644 booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.html create mode 100644 start.sh diff --git a/Dockerfile b/Dockerfile index 57a59ad94..581e0792b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,13 +27,14 @@ RUN gradle clean build # Stage 3: Final image FROM eclipse-temurin:21-jre-alpine -RUN apk update && apk add nginx +RUN apk update && apk add nginx gettext COPY ./nginx.conf /etc/nginx/nginx.conf COPY --from=angular-build /angular-app/dist/booklore/browser /usr/share/nginx/html COPY --from=springboot-build /springboot-app/build/libs/booklore-api-0.0.1-SNAPSHOT.jar /app/app.jar +COPY start.sh /start.sh +RUN chmod +x /start.sh EXPOSE 8080 80 -CMD /usr/sbin/nginx -g "daemon off;" & \ - java -jar /app/app.jar \ No newline at end of file +CMD ["/start.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 5c75ee6f1..ef6f86fa7 100644 --- a/README.md +++ b/README.md @@ -101,17 +101,19 @@ services: - DATABASE_URL=jdbc:mariadb://mariadb:3306/booklore # Only modify this if you're familiar with JDBC and your database setup - DATABASE_USERNAME=booklore # Must match MYSQL_USER defined in the mariadb container - DATABASE_PASSWORD=your_secure_password # Use a strong password; must match MYSQL_PASSWORD defined in the mariadb container + - BOOKLORE_PORT=6060 # Port BookLore listens on inside the container; must match container port below - SWAGGER_ENABLED=false # Enable or disable Swagger UI (API docs). Set to 'true' to allow access; 'false' to block access (recommended for production). depends_on: mariadb: condition: service_healthy ports: - - "6060:6060" + - "6060:6060" # HostPort:ContainerPort → Keep both numbers the same, and also ensure the container port matches BOOKLORE_PORT, no exceptions. + # All three (host port, container port, BOOKLORE_PORT) must be identical for BookLore to function properly. + # Example: To expose on host port 8080, set BOOKLORE_PORT=8080 and use "8080:8080". volumes: - - /your/local/path/to/booklore/data:/app/data # Internal app data (settings, metadata, cache) - - /your/local/path/to/booklore/books1:/books1 # Book library folder — point to one of your collections - - /your/local/path/to/booklore/books2:/books2 # Another book library — you can mount multiple library folders this way - - /your/local/path/to/booklore/bookdrop:/bookdrop # Bookdrop folder — drop new files here for automatic import into libraries + - /your/local/path/to/booklore/data:/app/data # Application data (settings, metadata, cache, etc.). Persist this folder to retain your library state across container restarts. + - /your/local/path/to/booklore/books:/books # Primary book library folder. Mount your collection here so BookLore can access and organize your books. + - /your/local/path/to/booklore/bookdrop:/bookdrop # BookDrop folder. Files placed here are automatically detected and prepared for import. restart: unless-stopped mariadb: @@ -137,7 +139,6 @@ services: Note: You can find the latest BookLore image tag `BOOKLORE_IMAGE_TAG` (e.g. v.0.x.x) from the Releases section: 📦 [Latest Image Tag – GitHub Releases](https://github.com/adityachandelgit/BookLore/releases) - ### 3️⃣ Start the Containers Run the following command to start the services: diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/LoggingFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/LoggingFilter.java new file mode 100644 index 000000000..84effc284 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/LoggingFilter.java @@ -0,0 +1,59 @@ +package com.adityachandel.booklore.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@Profile({"dev"}) +public class LoggingFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (request.getRequestURI().startsWith("/ws")) { + filterChain.doFilter(request, response); + return; + } + + long start = System.currentTimeMillis(); + + log.info("Incoming request: {} {} from IP {}", + request.getMethod(), + request.getRequestURI(), + request.getRemoteAddr()); + + ServletUriComponentsBuilder servletUriComponentsBuilder = ServletUriComponentsBuilder + .fromCurrentContextPath(); + + log.info("servletUriComponentsBuilder.toUriString(): {}", servletUriComponentsBuilder.toUriString()); + + var headerNames = request.getHeaderNames(); + if (headerNames != null) { + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + String headerValue = request.getHeader(headerName); + log.info("Header: {}={}", headerName, headerValue); + } + } + + filterChain.doFilter(request, response); + + long duration = System.currentTimeMillis() - start; + log.info("Completed {} {} with status {} in {} ms", + request.getMethod(), + request.getRequestURI(), + response.getStatus(), + duration); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/DualJwtAuthenticationFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/DualJwtAuthenticationFilter.java index ae54a31fc..38f96e889 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/DualJwtAuthenticationFilter.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/DualJwtAuthenticationFilter.java @@ -48,7 +48,8 @@ public class DualJwtAuthenticationFilter extends OncePerRequestFilter { private static final List WHITELISTED_PATHS = List.of( "/api/v1/opds/", "/api/v1/auth/refresh", - "/api/v1/setup/" + "/api/v1/setup/", + "/api/kobo/" ); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/KoboAuthFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/KoboAuthFilter.java new file mode 100644 index 000000000..0d8f6ec77 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/KoboAuthFilter.java @@ -0,0 +1,103 @@ +package com.adityachandel.booklore.config.security; + +import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.entity.UserPermissionsEntity; +import com.adityachandel.booklore.repository.KoboUserSettingsRepository; +import com.adityachandel.booklore.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class KoboAuthFilter extends OncePerRequestFilter { + + private final KoboUserSettingsRepository koboUserSettingsRepository; + private final UserRepository userRepository; + private final BookLoreUserTransformer bookLoreUserTransformer; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String path = request.getRequestURI(); + + if (!path.startsWith("/api/kobo/")) { + filterChain.doFilter(request, response); + return; + } + + String[] parts = path.split("/"); + if (parts.length < 4) { + log.warn("KOBO token missing in path"); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "KOBO token missing"); + return; + } + + String token = parts[3]; + + var userTokenOpt = koboUserSettingsRepository.findByToken(token); + if (userTokenOpt.isEmpty()) { + log.warn("Invalid KOBO token: {}", token); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid KOBO token"); + return; + } + + var userToken = userTokenOpt.get(); + var userOpt = userRepository.findById(userToken.getUserId()); + + if (userOpt.isEmpty()) { + log.warn("User not found for token: {}", token); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "User not found"); + return; + } + + var entity = userOpt.get(); + if (entity.getPermissions() == null || !entity.getPermissions().isPermissionSyncKobo()) { + log.warn("User {} does not have syncKobo permission", entity.getId()); + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Insufficient permissions"); + return; + } + + BookLoreUser user = bookLoreUserTransformer.toDTO(entity); + List authorities = getAuthorities(entity.getPermissions()); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, authorities); + authentication.setDetails(new UserAuthenticationDetails(request, user.getId())); + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } + + private List getAuthorities(UserPermissionsEntity permissions) { + List authorities = new ArrayList<>(); + if (permissions != null) { + addAuthorityIfPermissionGranted(authorities, "ROLE_UPLOAD", permissions.isPermissionUpload()); + addAuthorityIfPermissionGranted(authorities, "ROLE_DOWNLOAD", permissions.isPermissionDownload()); + addAuthorityIfPermissionGranted(authorities, "ROLE_EDIT_METADATA", permissions.isPermissionEditMetadata()); + addAuthorityIfPermissionGranted(authorities, "ROLE_MANIPULATE_LIBRARY", permissions.isPermissionManipulateLibrary()); + addAuthorityIfPermissionGranted(authorities, "ROLE_ADMIN", permissions.isPermissionAdmin()); + addAuthorityIfPermissionGranted(authorities, "ROLE_SYNC_KOBO", permissions.isPermissionSyncKobo()); + } + return authorities; + } + + private void addAuthorityIfPermissionGranted(List authorities, String role, boolean permissionGranted) { + if (permissionGranted) { + authorities.add(new SimpleGrantedAuthority(role)); + } + } +} \ No newline at end of file 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 b1ca05f9e..2eb70ae9f 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 @@ -36,6 +36,7 @@ public class SecurityConfig { private final CustomOpdsUserDetailsService customOpdsUserDetailsService; private final DualJwtAuthenticationFilter dualJwtAuthenticationFilter; + private final KoboAuthFilter koboAuthFilter; private final AppProperties appProperties; private static final String[] SWAGGER_ENDPOINTS = { @@ -46,6 +47,7 @@ public class SecurityConfig { private static final String[] COMMON_PUBLIC_ENDPOINTS = { "/ws/**", + "/kobo/**", "/api/v1/auth/**", "/api/v1/public-settings", "/api/v1/setup/**", @@ -104,6 +106,18 @@ public class SecurityConfig { @Bean @Order(3) + public SecurityFilterChain koboSecurityChain(HttpSecurity http, KoboAuthFilter koboAuthFilter) throws Exception { + http + .securityMatcher("/api/kobo/**") + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .addFilterBefore(koboAuthFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + @Order(4) public SecurityFilterChain jwtApiSecurityChain(HttpSecurity http) throws Exception { List publicEndpoints = new ArrayList<>(Arrays.asList(COMMON_PUBLIC_ENDPOINTS)); if (appProperties.getSwagger().isEnabled()) { 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 c1f651aab..697aa9639 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 @@ -50,6 +50,11 @@ public class SecurityUtil { return user != null && user.getPermissions().isCanSyncKoReader(); } + public boolean canSyncKobo() { + var user = getCurrentUser(); + return user != null && user.getPermissions().isCanSyncKobo(); + } + public boolean canEditMetadata() { var user = getCurrentUser(); return user != null && user.getPermissions().isCanEditMetadata(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboController.java new file mode 100644 index 000000000..5bf27bf67 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboController.java @@ -0,0 +1,162 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.Shelf; +import com.adityachandel.booklore.model.dto.kobo.KoboAuthentication; +import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper; +import com.adityachandel.booklore.model.dto.kobo.KoboResources; +import com.adityachandel.booklore.model.dto.kobo.KoboTestResponse; +import com.adityachandel.booklore.service.BookService; +import com.adityachandel.booklore.service.KoboEntitlementService; +import com.adityachandel.booklore.service.KoboReadingStateService; +import com.adityachandel.booklore.service.ShelfService; +import com.adityachandel.booklore.service.kobo.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Set; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping(value = "/api/kobo/{token}") +public class KoboController { + + private String token; + private final KoboServerProxy koboServerProxy; + private final KoboInitializationService koboInitializationService; + private final BookService bookService; + private final KoboReadingStateService koboReadingStateService; + private final KoboEntitlementService koboEntitlementService; + private final KoboDeviceAuthService koboDeviceAuthService; + private final KoboLibrarySyncService koboLibrarySyncService; + private final KoboThumbnailService koboThumbnailService; + private final ShelfService shelfService; + + @ModelAttribute + public void captureToken(@PathVariable("token") String token) { + this.token = token; + } + + @GetMapping("/v1/initialization") + public ResponseEntity initialization() throws JsonProcessingException { + return koboInitializationService.initialize(token); + } + + @GetMapping("/v1/library/sync") + public ResponseEntity syncLibrary(@AuthenticationPrincipal BookLoreUser user) { + return koboLibrarySyncService.syncLibrary(user, token); + } + + @GetMapping("/v1/books/{imageId}/thumbnail/{width}/{height}/false/image.jpg") + public ResponseEntity getThumbnail( + @PathVariable String imageId, + @PathVariable int width, + @PathVariable int height) { + + if (StringUtils.isNumeric(imageId)) { + return koboThumbnailService.getThumbnail(Long.valueOf(imageId)); + } else { + String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/image.jpg", imageId, width, height); + return koboServerProxy.proxyExternalUrl(cdnUrl); + } + } + + @GetMapping("/v1/books/{bookId}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg") + public ResponseEntity getGreyThumbnail( + @PathVariable String bookId, + @PathVariable int width, + @PathVariable int height, + @PathVariable int quality, + @PathVariable boolean isGreyscale) { + + if (StringUtils.isNumeric(bookId)) { + return koboThumbnailService.getThumbnail(Long.valueOf(bookId)); + } else { + String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/%d/%b/image.jpg", bookId, width, height, quality, isGreyscale); + return koboServerProxy.proxyExternalUrl(cdnUrl); + } + } + + @PostMapping("/v1/auth/device") + public ResponseEntity authenticateDevice(@RequestBody JsonNode body) { + return koboDeviceAuthService.authenticateDevice(body); + } + + @GetMapping("/v1/library/{bookId}/metadata") + public ResponseEntity getBookMetadata(@PathVariable String bookId) { + if (StringUtils.isNumeric(bookId)) { + return ResponseEntity.ok(List.of(koboEntitlementService.getMetadataForBook(Long.parseLong(bookId), token))); + } else { + return koboServerProxy.proxyCurrentRequest(null, false); + } + } + + @GetMapping("/v1/library/{bookId}/state") + public ResponseEntity getState(@PathVariable String bookId) { + if (StringUtils.isNumeric(bookId)) { + return ResponseEntity.ok(koboReadingStateService.getReadingState(bookId)); + } else { + return koboServerProxy.proxyCurrentRequest(null, false); + } + } + + @PutMapping("/v1/library/{bookId}/state") + public ResponseEntity updateState(@PathVariable String bookId, @RequestBody KoboReadingStateWrapper body) { + if (StringUtils.isNumeric(bookId)) { + return ResponseEntity.ok(koboReadingStateService.saveReadingState(body.getReadingStates())); + } else { + return koboServerProxy.proxyCurrentRequest(body, false); + } + } + + @PostMapping("/v1/analytics/gettests") + public ResponseEntity getTests(@RequestBody Object body) { + return ResponseEntity.ok(KoboTestResponse.builder() + .result("Success") + .testKey(RandomStringUtils.secure().nextAlphanumeric(24)) + .build()); + } + + @GetMapping("/v1/books/{bookId}/download") + public ResponseEntity downloadBook(@PathVariable String bookId) { + if (StringUtils.isNumeric(bookId)) { + return bookService.downloadBook(Long.parseLong(bookId)); + } else { + return koboServerProxy.proxyCurrentRequest(null, false); + } + } + + @DeleteMapping("/v1/library/{bookId}") + public ResponseEntity deleteBookFromLibrary(@PathVariable String bookId) { + if (StringUtils.isNumeric(bookId)) { + Shelf userKoboShelf = shelfService.getUserKoboShelf(); + bookService.assignShelvesToBooks(Set.of(Long.valueOf(bookId)), Set.of(), Set.of(userKoboShelf.getId())); + return ResponseEntity.ok().build(); + } else { + return koboServerProxy.proxyCurrentRequest(null, false); + } + } + + @RequestMapping(value = "/**", method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.PATCH}) + public ResponseEntity catchAll(HttpServletRequest request, @RequestBody(required = false) Object body) { + String path = request.getRequestURI(); + if (path.contains("/v1/analytics/event")) { + return ResponseEntity.ok().build(); + } + if (path.matches(".*/v1/products/\\d+/nextread.*")) { + return ResponseEntity.ok().build(); + } + return koboServerProxy.proxyCurrentRequest(body, false); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboSettingsController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboSettingsController.java new file mode 100644 index 000000000..acf86c64a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboSettingsController.java @@ -0,0 +1,39 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.KoboSyncSettings; +import com.adityachandel.booklore.service.KoboSettingsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/v1/kobo-settings") +@RequiredArgsConstructor +public class KoboSettingsController { + + private final KoboSettingsService koboService; + + @GetMapping + @PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()") + public ResponseEntity getSettings() { + KoboSyncSettings settings = koboService.getCurrentUserSettings(); + return ResponseEntity.ok(settings); + } + + @PutMapping + @PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()") + public ResponseEntity createOrUpdateToken() { + KoboSyncSettings updated = koboService.createOrUpdateToken(); + return ResponseEntity.ok(updated); + } + + @PatchMapping("/sync") + @PreAuthorize("@securityUtil.canSyncKobo() or @securityUtil.isAdmin()") + public ResponseEntity toggleSync(@RequestParam boolean enabled) { + koboService.setSyncEnabled(enabled); + return ResponseEntity.noContent().build(); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java index 583433630..ab466ab63 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java @@ -50,7 +50,8 @@ public enum ApiError { FILE_DELETION_DISABLED(HttpStatus.BAD_REQUEST, "File deletion is disabled"), UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "%s"), CONFLICT(HttpStatus.CONFLICT, "%s"), - FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "File not found: %s"); + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "File not found: %s"), + SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted" ),; private final HttpStatus status; private final String message; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookEntityToKoboSnapshotBookMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookEntityToKoboSnapshotBookMapper.java new file mode 100644 index 000000000..781c7590a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/BookEntityToKoboSnapshotBookMapper.java @@ -0,0 +1,15 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface BookEntityToKoboSnapshotBookMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "bookId", expression = "java(book.getId())") + @Mapping(target = "synced", constant = "false") + KoboSnapshotBookEntity toKoboSnapshotBook(BookEntity book); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/KoboReadingStateMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/KoboReadingStateMapper.java new file mode 100644 index 000000000..6ba71db87 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/KoboReadingStateMapper.java @@ -0,0 +1,53 @@ +package com.adityachandel.booklore.mapper; + +import com.adityachandel.booklore.model.dto.kobo.KoboReadingState; +import com.adityachandel.booklore.model.entity.KoboReadingStateEntity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface KoboReadingStateMapper { + + ObjectMapper objectMapper = new ObjectMapper(); + + @Mapping(target = "currentBookmarkJson", expression = "java(toJson(dto.getCurrentBookmark()))") + @Mapping(target = "statisticsJson", expression = "java(toJson(dto.getStatistics()))") + @Mapping(target = "statusInfoJson", expression = "java(toJson(dto.getStatusInfo()))") + @Mapping(target = "entitlementId", expression = "java(cleanString(dto.getEntitlementId()))") + @Mapping(target = "created", expression = "java(dto.getCreated())") + @Mapping(target = "lastModified", expression = "java(dto.getLastModified())") + @Mapping(target = "priorityTimestamp", expression = "java(dto.getPriorityTimestamp())") + KoboReadingStateEntity toEntity(KoboReadingState dto); + + @Mapping(target = "currentBookmark", expression = "java(fromJson(entity.getCurrentBookmarkJson(), KoboReadingState.CurrentBookmark.class))") + @Mapping(target = "statistics", expression = "java(fromJson(entity.getStatisticsJson(), KoboReadingState.Statistics.class))") + @Mapping(target = "statusInfo", expression = "java(fromJson(entity.getStatusInfoJson(), KoboReadingState.StatusInfo.class))") + @Mapping(target = "entitlementId", expression = "java(cleanString(entity.getEntitlementId()))") + @Mapping(target = "created", expression = "java(entity.getCreated())") + @Mapping(target = "lastModified", expression = "java(entity.getLastModified())") + @Mapping(target = "priorityTimestamp", expression = "java(entity.getPriorityTimestamp())") + KoboReadingState toDto(KoboReadingStateEntity entity); + + default String toJson(Object value) { + try { + return value == null ? null : objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize JSON", e); + } + } + + default T fromJson(String json, Class clazz) { + try { + return json == null ? null : objectMapper.readValue(json, clazz); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to deserialize JSON", e); + } + } + + default String cleanString(String value) { + if (value == null) return null; + return value.replaceAll("^\"|\"$", ""); + } +} \ No newline at end of file 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 841677d83..f176f9012 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 @@ -33,6 +33,7 @@ public class BookLoreUserTransformer { permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook()); permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary()); permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader()); + permissions.setCanSyncKobo(userEntity.getPermissions().isPermissionSyncKobo()); BookLoreUser bookLoreUser = new BookLoreUser(); bookLoreUser.setId(userEntity.getId()); 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 ef6c85b09..81cb176b8 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 @@ -29,6 +29,7 @@ public class BookLoreUser { private boolean canEditMetadata; private boolean canManipulateLibrary; private boolean canSyncKoReader; + private boolean canSyncKobo; private boolean canEmailBook; private boolean canDeleteBook; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookloreSyncToken.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookloreSyncToken.java new file mode 100644 index 000000000..be4b857eb --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookloreSyncToken.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class BookloreSyncToken { + private String ongoingSyncPointId; + private String lastSuccessfulSyncPointId; + private String rawKoboSyncToken; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/KoboSyncSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/KoboSyncSettings.java new file mode 100644 index 000000000..8bf2df0f3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/KoboSyncSettings.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.model.dto; + + +import lombok.Data; + +@Data +public class KoboSyncSettings { + private Long id; + private String userId; + private String token; + private boolean syncEnabled = true; +} 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 0213138d5..bb1de67be 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 @@ -18,6 +18,7 @@ public class UserCreateRequest { private boolean permissionEmailBook; private boolean permissionDeleteBook; private boolean permissionSyncKoreader; + private boolean permissionSyncKobo; private boolean permissionAdmin; private Set selectedLibraries; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlement.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlement.java new file mode 100644 index 000000000..213441d90 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlement.java @@ -0,0 +1,56 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BookEntitlement { + private ActivePeriod activePeriod; + + @JsonProperty("IsRemoved") + private boolean isRemoved; + + private String status; + + @Builder.Default + private String accessibility = "Full"; + + private String crossRevisionId; + private String revisionId; + + @JsonProperty("IsHiddenFromArchive") + @Builder.Default + private boolean isHiddenFromArchive = false; + + private String id; + private String created; + private String lastModified; + + @JsonProperty("IsLocked") + @Builder.Default + private boolean isLocked = false; + + @Builder.Default + private String originCategory = "Imported"; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ActivePeriod { + private String from; + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlementContainer.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlementContainer.java new file mode 100644 index 000000000..0ddbe43cb --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlementContainer.java @@ -0,0 +1,21 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BookEntitlementContainer { + private BookEntitlement bookEntitlement; + private KoboBookMetadata bookMetadata; + private KoboReadingState readingState; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/ChangedEntitlement.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/ChangedEntitlement.java new file mode 100644 index 000000000..7ee3f937c --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/ChangedEntitlement.java @@ -0,0 +1,18 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +public class ChangedEntitlement implements Entitlement { + + private BookEntitlementContainer changedEntitlement; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/Entitlement.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/Entitlement.java new file mode 100644 index 000000000..5bf9a47c7 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/Entitlement.java @@ -0,0 +1,4 @@ +package com.adityachandel.booklore.model.dto.kobo; + +public interface Entitlement { +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboAuthentication.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboAuthentication.java new file mode 100644 index 000000000..fb9a5ada1 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboAuthentication.java @@ -0,0 +1,21 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +public class KoboAuthentication { + private String accessToken; + private String refreshToken; + private String tokenType = "Bearer"; + private String trackingId; + private String userKey; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboBookMetadata.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboBookMetadata.java new file mode 100644 index 000000000..2b4776878 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboBookMetadata.java @@ -0,0 +1,159 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class KoboBookMetadata { + private String crossRevisionId; + private String revisionId; + private Publisher publisher; + private String publicationDate; + + @Builder.Default + private String language = "en"; + + private String isbn; + private String genre; + private String slug; + private String coverImageId; + + @JsonProperty("IsSocialEnabled") + @Builder.Default + private boolean isSocialEnabled = false; + + private String workId; + + @Builder.Default + private List externalIds = new ArrayList<>(); + + @JsonProperty("IsPreOrder") + @Builder.Default + private boolean isPreOrder = false; + + @Builder.Default + private List contributorRoles = new ArrayList<>(); + + @JsonProperty("IsInternetArchive") + @Builder.Default + private boolean isInternetArchive = false; + + private String entitlementId; + private String title; + private String description; + + @Builder.Default + private List categories = List.of("00000000-0000-0000-0000-000000000001"); + + @Builder.Default + private List downloadUrls = new ArrayList<>(); + + @Builder.Default + private List contributors = new ArrayList<>(); + + private Series series; + + @Builder.Default + private CurrentDisplayPrice currentDisplayPrice = CurrentDisplayPrice.builder() + .totalAmount(0) + .currencyCode("USD") + .build(); + + @Builder.Default + private CurrentLoveDisplayPrice currentLoveDisplayPrice = CurrentLoveDisplayPrice.builder() + .totalAmount(0) + .build(); + + @JsonProperty("IsEligibleForKoboLove") + @Builder.Default + private boolean isEligibleForKoboLove = false; + + @Builder.Default + private Map phoneticPronunciations = Map.of(); + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Publisher { + private String name; + private String imprint; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ContributorRole { + private String name; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DownloadUrl { + @Builder.Default + private String drmType = "None"; + private String format; + private String url; + private long size; + @Builder.Default + private String platform = "Generic"; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Series { + private String id; + private String name; + private String number; + private double numberFloat; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CurrentDisplayPrice { + private double totalAmount; + private String currencyCode; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CurrentLoveDisplayPrice { + private double totalAmount; + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboHeaders.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboHeaders.java new file mode 100644 index 000000000..6dcc9424f --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboHeaders.java @@ -0,0 +1,11 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class KoboHeaders { + + public static final String X_KOBO_SYNCTOKEN = "x-kobo-synctoken"; + public static final String X_KOBO_SYNC = "X-Kobo-sync"; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboReadingState.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboReadingState.java new file mode 100644 index 000000000..cdf7fddb6 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboReadingState.java @@ -0,0 +1,77 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.adityachandel.booklore.model.enums.KoboReadStatus; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class KoboReadingState { + private String entitlementId; + private String created; + private String lastModified; + private StatusInfo statusInfo; + private Statistics statistics; + private CurrentBookmark currentBookmark; + private String priorityTimestamp; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class StatusInfo { + private String lastModified; + private KoboReadStatus status; + private int timesStartedReading; + private String lastTimeStartedReading; + private String lastTimeFinished; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Statistics { + private String lastModified; + private Integer spentReadingMinutes; + private Integer remainingTimeMinutes; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CurrentBookmark { + private String lastModified; + private Integer progressPercent; + private Integer contentSourceProgressPercent; + private Location location; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Location { + private String value; + private String type; + private String source; + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboReadingStateWrapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboReadingStateWrapper.java new file mode 100644 index 000000000..7c67400f6 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboReadingStateWrapper.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +public class KoboReadingStateWrapper { + private List readingStates; +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboResources.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboResources.java new file mode 100644 index 000000000..3de70af2a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboResources.java @@ -0,0 +1,18 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class KoboResources { + private JsonNode resources; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboTestResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboTestResponse.java new file mode 100644 index 000000000..aa060ba5a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboTestResponse.java @@ -0,0 +1,26 @@ +package com.adityachandel.booklore.model.dto.kobo; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KoboTestResponse { + + @JsonProperty("Result") + private String result; + + @JsonProperty("TestKey") + private String testKey; + + @JsonProperty("Tests") + private Map tests; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/NewEntitlement.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/NewEntitlement.java new file mode 100644 index 000000000..d83b6f531 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/NewEntitlement.java @@ -0,0 +1,19 @@ +package com.adityachandel.booklore.model.dto.kobo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class NewEntitlement implements Entitlement { + private BookEntitlementContainer newEntitlement; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ShelfCreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ShelfCreateRequest.java index a21c35aff..09b2ad361 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ShelfCreateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ShelfCreateRequest.java @@ -2,8 +2,10 @@ package com.adityachandel.booklore.model.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Null; +import lombok.Builder; import lombok.Data; +@Builder @Data public class ShelfCreateRequest { 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 84a6357eb..79dd229db 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 @@ -21,5 +21,6 @@ public class UserUpdateRequest { private boolean canEmailBook; private boolean canDeleteBook; private boolean canSyncKoReader; + private boolean canSyncKobo; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/kobo/KoboReadingStateResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/kobo/KoboReadingStateResponse.java new file mode 100644 index 000000000..43f3c26ce --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/kobo/KoboReadingStateResponse.java @@ -0,0 +1,32 @@ +package com.adityachandel.booklore.model.dto.response.kobo; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class KoboReadingStateResponse { + private String requestResult; + private List updateResults; + + @Data + @Builder + public static class UpdateResult { + private String entitlementId; + private Result currentBookmarkResult; + private Result statisticsResult; + private Result statusInfoResult; + } + + @Data + @Builder + public static class Result { + private String result; + + public static Result success() { + return Result.builder().result("Success").build(); + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboDeletedBookProgressEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboDeletedBookProgressEntity.java new file mode 100644 index 000000000..f1f14f291 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboDeletedBookProgressEntity.java @@ -0,0 +1,26 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "kobo_removed_books_tracking") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class KoboDeletedBookProgressEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "snapshot_id", nullable = false) + private String snapshotId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "book_id_synced", nullable = false) + private Long bookIdSynced; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboLibrarySnapshotEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboLibrarySnapshotEntity.java new file mode 100644 index 000000000..ba046fd81 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboLibrarySnapshotEntity.java @@ -0,0 +1,29 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "kobo_library_snapshot") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class KoboLibrarySnapshotEntity { + + @Id + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "created_date", nullable = false) + private LocalDateTime createdDate; + + @OneToMany(mappedBy = "snapshot", cascade = CascadeType.ALL, orphanRemoval = true) + private List books; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboReadingStateEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboReadingStateEntity.java new file mode 100644 index 000000000..2d5fc2284 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboReadingStateEntity.java @@ -0,0 +1,44 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "kobo_reading_state") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class KoboReadingStateEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "entitlement_id", nullable = false, unique = true) + private String entitlementId; + + @Column(name = "created") + private String created; + + @UpdateTimestamp + @Column(name = "last_modified") + private String lastModified; + + @Column(name = "priority_timestamp") + private String priorityTimestamp; + + @Column(name = "current_bookmark_json", columnDefinition = "json") + private String currentBookmarkJson; + + @Column(name = "statistics_json", columnDefinition = "json") + private String statisticsJson; + + @Column(name = "status_info_json", columnDefinition = "json") + private String statusInfoJson; + + @Column(name = "last_modified_string") + private String lastModifiedString; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboSnapshotBookEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboSnapshotBookEntity.java new file mode 100644 index 000000000..f937f68cc --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboSnapshotBookEntity.java @@ -0,0 +1,29 @@ +package com.adityachandel.booklore.model.entity; + + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "kobo_library_snapshot_book") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class KoboSnapshotBookEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "snapshot_id", nullable = false) + private KoboLibrarySnapshotEntity snapshot; + + @Column(name = "book_id", nullable = false) + private Long bookId; + + @Column(nullable = false) + private boolean synced = false; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboUserSettingsEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboUserSettingsEntity.java new file mode 100644 index 000000000..8336b6134 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboUserSettingsEntity.java @@ -0,0 +1,27 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@Setter +@Entity +@Table(name = "kobo_user_settings") +public class KoboUserSettingsEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @Column(name = "token", nullable = false, length = 2048) + private String token; + + @Column(name = "sync_enabled") + private boolean syncEnabled = true; +} 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 9d02dcd88..5932c70c9 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_sync_kobo", nullable = false) + private boolean permissionSyncKobo = false; + @Column(name = "permission_admin", nullable = false) private boolean permissionAdmin; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/KoboReadStatus.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/KoboReadStatus.java new file mode 100644 index 000000000..3129ef3e4 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/KoboReadStatus.java @@ -0,0 +1,17 @@ +package com.adityachandel.booklore.model.enums; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) +public enum KoboReadStatus { + @JsonProperty("ReadyToRead") + READY_TO_READ, + + @JsonProperty("Finished") + FINISHED, + + @JsonProperty("Reading") + READING, +} 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 541ab7d8c..c40b156d0 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 @@ -8,5 +8,6 @@ public enum PermissionType { EMAIL_BOOK, DELETE_BOOK, SYNC_KOREADER, + SYNC_KOBO, ADMIN } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ShelfType.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ShelfType.java new file mode 100644 index 000000000..03ff3c267 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ShelfType.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.model.enums; + +import lombok.Getter; + +@Getter +public enum ShelfType { + KOBO("Kobo", "pi pi-tablet"); + + private final String name; + private final String icon; + + ShelfType(String name, String icon) { + this.name = name; + this.icon = icon; + } +} 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 7d9b66b5e..44f8557b0 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,6 +2,7 @@ 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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookShelfMappingRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookShelfMappingRepository.java index 7f5978749..e224c7322 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookShelfMappingRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/BookShelfMappingRepository.java @@ -10,6 +10,4 @@ import java.util.Set; @Repository public interface BookShelfMappingRepository extends JpaRepository { - - List findAllByBookIdIn(Set bookId); } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboDeletedBookProgressRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboDeletedBookProgressRepository.java new file mode 100644 index 000000000..dee3c99ca --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboDeletedBookProgressRepository.java @@ -0,0 +1,18 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.KoboDeletedBookProgressEntity; +import jakarta.transaction.Transactional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface KoboDeletedBookProgressRepository extends JpaRepository { + + @Modifying + @Transactional + @Query("DELETE FROM KoboDeletedBookProgressEntity p WHERE p.snapshotId = :snapshotId AND p.userId = :userId") + void deleteBySnapshotIdAndUserId(@Param("snapshotId") String snapshotId, @Param("userId") Long userId); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboLibrarySnapshotRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboLibrarySnapshotRepository.java new file mode 100644 index 000000000..1fd799e7a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboLibrarySnapshotRepository.java @@ -0,0 +1,16 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface KoboLibrarySnapshotRepository extends JpaRepository { + + Optional findByIdAndUserId(String id, Long userId); + + Optional findTopByUserIdOrderByCreatedDateDesc(Long userId); + +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboReadingStateRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboReadingStateRepository.java new file mode 100644 index 000000000..b43d0639e --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboReadingStateRepository.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.KoboReadingStateEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface KoboReadingStateRepository extends JpaRepository { + Optional findByEntitlementId(String entitlementId); +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboSnapshotBookRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboSnapshotBookRepository.java new file mode 100644 index 000000000..fbf3927fe --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboSnapshotBookRepository.java @@ -0,0 +1,77 @@ +package com.adityachandel.booklore.repository; + + +import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface KoboSnapshotBookRepository extends JpaRepository { + + Page findBySnapshot_IdAndSyncedFalse(String snapshotId, Pageable pageable); + + @Modifying + @Query("UPDATE KoboSnapshotBookEntity b SET b.synced = true WHERE b.snapshot.id = :snapshotId AND b.bookId IN :bookIds") + void markBooksSynced(@Param("snapshotId") String snapshotId, @Param("bookIds") List bookIds); + + @Query(""" + SELECT curr + FROM KoboSnapshotBookEntity curr + WHERE curr.snapshot.id = :currSnapshotId + AND curr.bookId IN ( + SELECT prev.bookId + FROM KoboSnapshotBookEntity prev + WHERE prev.snapshot.id = :prevSnapshotId + ) + """) + List findExistingBooksBetweenSnapshots( + @Param("prevSnapshotId") String prevSnapshotId, + @Param("currSnapshotId") String currSnapshotId + ); + + @Query(""" + SELECT curr + FROM KoboSnapshotBookEntity curr + WHERE curr.snapshot.id = :currSnapshotId + AND (:unsyncedOnly = false OR curr.synced = false) + AND curr.bookId NOT IN ( + SELECT prev.bookId + FROM KoboSnapshotBookEntity prev + WHERE prev.snapshot.id = :prevSnapshotId + ) + """) + Page findNewlyAddedBooks( + @Param("prevSnapshotId") String prevSnapshotId, + @Param("currSnapshotId") String currSnapshotId, + @Param("unsyncedOnly") boolean unsyncedOnly, + Pageable pageable + ); + + @Query(""" + SELECT prev + FROM KoboSnapshotBookEntity prev + WHERE prev.snapshot.id = :prevSnapshotId + AND prev.bookId NOT IN ( + SELECT curr.bookId + FROM KoboSnapshotBookEntity curr + WHERE curr.snapshot.id = :currSnapshotId + ) + AND prev.bookId NOT IN ( + SELECT p.bookIdSynced + FROM KoboDeletedBookProgressEntity p + WHERE p.snapshotId = :currSnapshotId + ) + """) + Page findRemovedBooks( + @Param("prevSnapshotId") String prevSnapshotId, + @Param("currSnapshotId") String currSnapshotId, + Pageable pageable + ); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboUserSettingsRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboUserSettingsRepository.java new file mode 100644 index 000000000..24d4714e9 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/KoboUserSettingsRepository.java @@ -0,0 +1,15 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface KoboUserSettingsRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + Optional findByToken(String token); +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/ShelfRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/ShelfRepository.java index e76f3eab8..dd85b550d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/ShelfRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/ShelfRepository.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository; import java.util.Collection; import java.util.List; +import java.util.Optional; import java.util.Set; @Repository @@ -16,5 +17,5 @@ public interface ShelfRepository extends JpaRepository { List findByUserId(Long id); - List findAllByIdIn(Set ids); + Optional findByUserIdAndName(Long id, String name); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboEntitlementService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboEntitlementService.java new file mode 100644 index 000000000..6d06ab6b2 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboEntitlementService.java @@ -0,0 +1,187 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.model.dto.kobo.*; +import com.adityachandel.booklore.model.entity.AuthorEntity; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.entity.CategoryEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.KoboReadStatus; +import com.adityachandel.booklore.util.kobo.KoboUrlBuilder; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@AllArgsConstructor +@Service +public class KoboEntitlementService { + + private final KoboUrlBuilder koboUrlBuilder; + private final BookQueryService bookQueryService; + + public List generateNewEntitlements(Set bookIds, String token, boolean removed) { + List books = bookQueryService.findAllWithMetadataByIds(bookIds); + + return books.stream() + .filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB) + .map(book -> NewEntitlement.builder() + .newEntitlement(BookEntitlementContainer.builder() + .bookEntitlement(buildBookEntitlement(book, removed)) + .bookMetadata(mapToKoboMetadata(book, token)) + .readingState(createInitialReadingState(book)) + .build()) + .build()) + .collect(Collectors.toList()); + } + + public List generateChangedEntitlements(Set bookIds, String token, boolean removed) { + List books = bookQueryService.findAllWithMetadataByIds(bookIds); + return books.stream() + .filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB) + .map(book -> { + KoboBookMetadata metadata; + if (removed) { + metadata = KoboBookMetadata.builder() + .coverImageId(String.valueOf(book.getId())) + .crossRevisionId(String.valueOf(book.getId())) + .entitlementId(String.valueOf(book.getId())) + .revisionId(String.valueOf(book.getId())) + .workId(String.valueOf(book.getId())) + .title(String.valueOf(book.getId())) + .build(); + } else { + metadata = mapToKoboMetadata(book, token); + } + return ChangedEntitlement.builder() + .changedEntitlement(BookEntitlementContainer.builder() + .bookEntitlement(buildBookEntitlement(book, true)) + .bookMetadata(metadata) + .build()) + .build(); + }) + .collect(Collectors.toList()); + } + + private KoboReadingState createInitialReadingState(BookEntity book) { + OffsetDateTime now = getCurrentUtc(); + OffsetDateTime createdOn = getCreatedOn(book); + + return KoboReadingState.builder() + .entitlementId(String.valueOf(book.getId())) + .created(createdOn.toString()) + .lastModified(now.toString()) + .statusInfo(KoboReadingState.StatusInfo.builder() + .lastModified(now.toString()) + .status(KoboReadStatus.READY_TO_READ) + .timesStartedReading(0) + .build()) + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .lastModified(now.toString()) + .build()) + .statistics(KoboReadingState.Statistics.builder() + .lastModified(now.toString()) + .build()) + .priorityTimestamp(now.toString()) + .build(); + } + + private BookEntitlement buildBookEntitlement(BookEntity book, boolean removed) { + OffsetDateTime now = getCurrentUtc(); + OffsetDateTime createdOn = getCreatedOn(book); + + return BookEntitlement.builder() + .activePeriod(BookEntitlement.ActivePeriod.builder() + .from(now.toString()) + .build()) + .isRemoved(removed) + .status("Active") + .crossRevisionId(String.valueOf(book.getId())) + .revisionId(String.valueOf(book.getId())) + .id(String.valueOf(book.getId())) + .created(createdOn.toString()) + .lastModified(now.toString()) + .build(); + } + + public KoboBookMetadata getMetadataForBook(long bookId, String token) { + List books = bookQueryService.findAllWithMetadataByIds(Set.of(bookId)) + .stream() + .filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB) + .toList(); + return mapToKoboMetadata(books.getFirst(), token); + } + + private KoboBookMetadata mapToKoboMetadata(BookEntity book, String token) { + BookMetadataEntity metadata = book.getMetadata(); + + KoboBookMetadata.Publisher publisher = KoboBookMetadata.Publisher.builder() + .name(metadata.getPublisher()) + .imprint(metadata.getPublisher()) + .build(); + + List authors = Optional.ofNullable(metadata.getAuthors()) + .map(list -> list.stream().map(AuthorEntity::getName).toList()) + .orElse(Collections.emptyList()); + + List categories = Optional.ofNullable(metadata.getCategories()) + .map(list -> list.stream().map(CategoryEntity::getName).toList()) + .orElse(Collections.emptyList()); + + KoboBookMetadata.Series series = null; + if (metadata.getSeriesName() != null) { + series = KoboBookMetadata.Series.builder() + .id("series_" + metadata.getSeriesName().hashCode()) + .name(metadata.getSeriesName()) + .number(metadata.getSeriesNumber() != null ? metadata.getSeriesNumber().toString() : "1") + .numberFloat(metadata.getSeriesNumber() != null ? metadata.getSeriesNumber().doubleValue() : 1.0) + .build(); + } + + String downloadUrl = koboUrlBuilder.downloadUrl(token, book.getId()); + + return KoboBookMetadata.builder() + .crossRevisionId(String.valueOf(book.getId())) + .revisionId(String.valueOf(book.getId())) + .publisher(publisher) + .publicationDate(metadata.getPublishedDate() != null + ? metadata.getPublishedDate().atStartOfDay().atOffset(ZoneOffset.UTC).toString() + : null) + .isbn(metadata.getIsbn13() != null ? metadata.getIsbn13() : metadata.getIsbn10()) + .genre(categories.isEmpty() ? null : categories.getFirst()) + .slug(metadata.getTitle() != null + ? metadata.getTitle().toLowerCase().replaceAll("[^a-z0-9]", "-") + : null) + .coverImageId(String.valueOf(metadata.getBookId())) + .workId(String.valueOf(book.getId())) + .isPreOrder(false) + .contributorRoles(Collections.emptyList()) + .entitlementId(String.valueOf(book.getId())) + .title(metadata.getTitle()) + .description(metadata.getDescription()) + .contributors(authors) + .series(series) + .downloadUrls(List.of( + KoboBookMetadata.DownloadUrl.builder() + .url(downloadUrl) + .format("EPUB3") + .size(book.getFileSizeKb() * 1024) + .build() + )) + .build(); + } + + private OffsetDateTime getCurrentUtc() { + return OffsetDateTime.now(ZoneOffset.UTC); + } + + private OffsetDateTime getCreatedOn(BookEntity book) { + return book.getAddedOn() != null ? book.getAddedOn().atOffset(ZoneOffset.UTC) : getCurrentUtc(); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboLibrarySnapshotService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboLibrarySnapshotService.java new file mode 100644 index 000000000..2d4c9a4cb --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboLibrarySnapshotService.java @@ -0,0 +1,133 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.mapper.BookEntityToKoboSnapshotBookMapper; +import com.adityachandel.booklore.model.entity.KoboDeletedBookProgressEntity; +import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity; +import com.adityachandel.booklore.model.entity.ShelfEntity; +import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.ShelfType; +import com.adityachandel.booklore.repository.KoboDeletedBookProgressRepository; +import com.adityachandel.booklore.repository.ShelfRepository; +import com.adityachandel.booklore.repository.KoboSnapshotBookRepository; +import com.adityachandel.booklore.repository.KoboLibrarySnapshotRepository; +import lombok.AllArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@AllArgsConstructor +@Service +public class KoboLibrarySnapshotService { + + private final KoboLibrarySnapshotRepository koboLibrarySnapshotRepository; + private final KoboSnapshotBookRepository koboSnapshotBookRepository; + private final ShelfRepository shelfRepository; + private final BookEntityToKoboSnapshotBookMapper mapper; + private final KoboDeletedBookProgressRepository koboDeletedBookProgressRepository; + + @Transactional(readOnly = true) + public Optional findByIdAndUserId(String id, Long userId) { + return koboLibrarySnapshotRepository.findByIdAndUserId(id, userId); + } + + @Transactional + public KoboLibrarySnapshotEntity create(Long userId) { + KoboLibrarySnapshotEntity snapshot = KoboLibrarySnapshotEntity.builder() + .id(UUID.randomUUID().toString()) + .userId(userId) + .build(); + + List books = mapBooksToKoboSnapshotBook(getKoboShelf(userId), snapshot); + snapshot.setBooks(books); + + return koboLibrarySnapshotRepository.save(snapshot); + } + + @Transactional + public Page getUnsyncedBooks(String snapshotId, Pageable pageable) { + Page page = koboSnapshotBookRepository.findBySnapshot_IdAndSyncedFalse(snapshotId, pageable); + List bookIds = page.getContent().stream() + .map(KoboSnapshotBookEntity::getBookId) + .toList(); + if (!bookIds.isEmpty()) { + koboSnapshotBookRepository.markBooksSynced(snapshotId, bookIds); + } + return page; + } + + @Transactional + public void updateSyncedStatusForExistingBooks(String previousSnapshotId, String currentSnapshotId) { + List list = koboSnapshotBookRepository.findExistingBooksBetweenSnapshots(previousSnapshotId, currentSnapshotId); + List existingBooks = list.stream() + .map(KoboSnapshotBookEntity::getBookId) + .toList(); + + if (!existingBooks.isEmpty()) { + koboSnapshotBookRepository.markBooksSynced(currentSnapshotId, existingBooks); + } + } + + @Transactional + public Page getNewlyAddedBooks(String previousSnapshotId, String currentSnapshotId, Pageable pageable, Long userId) { + Page page = koboSnapshotBookRepository.findNewlyAddedBooks(previousSnapshotId, currentSnapshotId, true, pageable); + List newlyAddedBookIds = page.getContent().stream() + .map(KoboSnapshotBookEntity::getBookId) + .toList(); + + if (!newlyAddedBookIds.isEmpty()) { + koboSnapshotBookRepository.markBooksSynced(currentSnapshotId, newlyAddedBookIds); + } + + return page; + } + + @Transactional + public Page getRemovedBooks(String previousSnapshotId, String currentSnapshotId, Long userId, Pageable pageable) { + Page page = koboSnapshotBookRepository.findRemovedBooks(previousSnapshotId, currentSnapshotId, pageable); + + List bookIds = page.getContent().stream() + .map(KoboSnapshotBookEntity::getBookId) + .toList(); + + if (!bookIds.isEmpty()) { + List progressEntities = bookIds.stream() + .map(bookId -> KoboDeletedBookProgressEntity.builder() + .bookIdSynced(bookId) + .snapshotId(currentSnapshotId) + .userId(userId) + .build()) + .toList(); + + koboDeletedBookProgressRepository.saveAll(progressEntities); + } + return page; + } + + private ShelfEntity getKoboShelf(Long userId) { + return shelfRepository + .findByUserIdAndName(userId, ShelfType.KOBO.getName()) + .orElseThrow(() -> new NoSuchElementException( + String.format("Shelf '%s' not found for user %d", ShelfType.KOBO.getName(), userId) + )); + } + + private List mapBooksToKoboSnapshotBook(ShelfEntity shelf, KoboLibrarySnapshotEntity snapshot) { + return shelf.getBookEntities().stream() + .filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB) + .map(book -> { + KoboSnapshotBookEntity snapshotBook = mapper.toKoboSnapshotBook(book); + snapshotBook.setSnapshot(snapshot); + return snapshotBook; + }) + .collect(Collectors.toList()); + } + + public void deleteById(String id) { + koboLibrarySnapshotRepository.deleteById(id); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboReadingStateService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboReadingStateService.java new file mode 100644 index 000000000..80507d753 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboReadingStateService.java @@ -0,0 +1,70 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.mapper.KoboReadingStateMapper; +import com.adityachandel.booklore.model.dto.kobo.KoboReadingState; +import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper; +import com.adityachandel.booklore.model.dto.response.kobo.KoboReadingStateResponse; +import com.adityachandel.booklore.model.entity.KoboReadingStateEntity; +import com.adityachandel.booklore.repository.KoboReadingStateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class KoboReadingStateService { + + private final KoboReadingStateRepository repository; + private final KoboReadingStateMapper mapper; + + public KoboReadingStateResponse saveReadingState(List readingStates) { + List koboReadingStates = saveAll(readingStates); + + List updateResults = koboReadingStates.stream() + .map(state -> KoboReadingStateResponse.UpdateResult.builder() + .entitlementId(state.getEntitlementId()) + .currentBookmarkResult(KoboReadingStateResponse.Result.success()) + .statisticsResult(KoboReadingStateResponse.Result.success()) + .statusInfoResult(KoboReadingStateResponse.Result.success()) + .build()) + .collect(Collectors.toList()); + + return KoboReadingStateResponse.builder() + .requestResult("Success") + .updateResults(updateResults) + .build(); + } + + private List saveAll(List dtos) { + return dtos.stream() + .map(dto -> { + KoboReadingStateEntity entity = repository.findByEntitlementId(dto.getEntitlementId()) + .map(existing -> { + existing.setCurrentBookmarkJson(mapper.toJson(dto.getCurrentBookmark())); + existing.setStatisticsJson(mapper.toJson(dto.getStatistics())); + existing.setStatusInfoJson(mapper.toJson(dto.getStatusInfo())); + existing.setLastModifiedString(mapper.cleanString(String.valueOf(dto.getLastModified()))); + return existing; + }) + .orElseGet(() -> { + KoboReadingStateEntity newEntity = mapper.toEntity(dto); + newEntity.setCreated(mapper.cleanString(String.valueOf(dto.getCreated()))); + return newEntity; + }); + + return repository.save(entity); + }) + .map(mapper::toDto) + .collect(Collectors.toList()); + } + + public KoboReadingStateWrapper getReadingState(String entitlementId) { + Optional readingState = repository.findByEntitlementId(entitlementId).map(mapper::toDto); + return readingState.map(state -> KoboReadingStateWrapper.builder() + .readingStates(List.of(state)) + .build()).orElse(null); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboSettingsService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboSettingsService.java new file mode 100644 index 000000000..691617800 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/KoboSettingsService.java @@ -0,0 +1,97 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.security.AuthenticationService; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.KoboSyncSettings; +import com.adityachandel.booklore.model.dto.request.ShelfCreateRequest; +import com.adityachandel.booklore.model.entity.KoboUserSettingsEntity; +import com.adityachandel.booklore.model.entity.ShelfEntity; +import com.adityachandel.booklore.model.enums.ShelfType; +import com.adityachandel.booklore.repository.KoboUserSettingsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class KoboSettingsService { + + private final KoboUserSettingsRepository repository; + private final AuthenticationService authenticationService; + private final ShelfService shelfService; + + @Transactional(readOnly = true) + public KoboSyncSettings getCurrentUserSettings() { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + KoboUserSettingsEntity entity = repository.findByUserId(user.getId()) + .orElseGet(() -> initDefaultSettings(user.getId())); + return mapToDto(entity); + } + + @Transactional + public KoboSyncSettings createOrUpdateToken() { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + String newToken = generateToken(); + + KoboUserSettingsEntity entity = repository.findByUserId(user.getId()) + .map(existing -> { + existing.setToken(newToken); + return existing; + }) + .orElseGet(() -> KoboUserSettingsEntity.builder() + .userId(user.getId()) + .token(newToken) + .build()); + + ensureKoboShelfExists(user.getId()); + repository.save(entity); + + return mapToDto(entity); + } + + @Transactional + public void setSyncEnabled(boolean enabled) { + BookLoreUser user = authenticationService.getAuthenticatedUser(); + KoboUserSettingsEntity entity = repository.findByUserId(user.getId()) + .orElseThrow(() -> new IllegalStateException("Kobo settings not found for user")); + + entity.setSyncEnabled(enabled); + repository.save(entity); + } + + private KoboUserSettingsEntity initDefaultSettings(Long userId) { + ensureKoboShelfExists(userId); + KoboUserSettingsEntity entity = KoboUserSettingsEntity.builder() + .userId(userId) + .token(generateToken()) + .build(); + return repository.save(entity); + } + + private void ensureKoboShelfExists(Long userId) { + Optional shelf = shelfService.getShelf(userId, ShelfType.KOBO.getName()); + if (shelf.isEmpty()) { + shelfService.createShelf( + ShelfCreateRequest.builder() + .name(ShelfType.KOBO.getName()) + .icon(ShelfType.KOBO.getIcon()) + .build() + ); + } + } + + private String generateToken() { + return UUID.randomUUID().toString(); + } + + private KoboSyncSettings mapToDto(KoboUserSettingsEntity entity) { + KoboSyncSettings dto = new KoboSyncSettings(); + dto.setId(entity.getId()); + dto.setUserId(entity.getUserId().toString()); + dto.setToken(entity.getToken()); + return dto; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/ShelfService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/ShelfService.java index 904a51175..9b8789689 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/ShelfService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/ShelfService.java @@ -10,6 +10,7 @@ import com.adityachandel.booklore.model.dto.Shelf; import com.adityachandel.booklore.model.dto.request.ShelfCreateRequest; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.model.entity.ShelfEntity; +import com.adityachandel.booklore.model.enums.ShelfType; import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.ShelfRepository; import com.adityachandel.booklore.repository.UserRepository; @@ -18,6 +19,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Optional; @AllArgsConstructor @Service @@ -62,10 +64,20 @@ public class ShelfService { } public void deleteShelf(Long shelfId) { - findShelfByIdOrThrow(shelfId); + ShelfEntity shelfEntity = findShelfByIdOrThrow(shelfId); + if (shelfEntity.getName().equalsIgnoreCase(ShelfType.KOBO.getName())) { + throw ApiError.SHELF_CANNOT_BE_DELETED.createException(ShelfType.KOBO.getName()); + } shelfRepository.deleteById(shelfId); } + public Shelf getUserKoboShelf() { + Long userId = getAuthenticatedUserId(); + ShelfEntity koboShelf = shelfRepository.findByUserIdAndName(userId, ShelfType.KOBO.getName()) + .orElseThrow(() -> ApiError.SHELF_NOT_FOUND.createException(ShelfType.KOBO.getName())); + return shelfMapper.toShelf(koboShelf); + } + public List getShelfBooks(Long shelfId) { findShelfByIdOrThrow(shelfId); return bookRepository.findAllWithMetadataByShelfId(shelfId).stream() @@ -87,4 +99,8 @@ public class ShelfService { return shelfRepository.findById(shelfId) .orElseThrow(() -> ApiError.SHELF_NOT_FOUND.createException(shelfId)); } + + public Optional getShelf(Long id, String name) { + return shelfRepository.findByUserIdAndName(id, name); + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboDeviceAuthService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboDeviceAuthService.java new file mode 100644 index 000000000..34f77213b --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboDeviceAuthService.java @@ -0,0 +1,50 @@ +package com.adityachandel.booklore.service.kobo; + +import com.adityachandel.booklore.model.dto.kobo.KoboAuthentication; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KoboDeviceAuthService { + + private final ObjectMapper objectMapper; + + public ResponseEntity authenticateDevice(JsonNode requestBody) { + if (requestBody == null || requestBody.get("UserKey") == null) { + throw new IllegalArgumentException("UserKey is required"); + } + + log.info("Kobo device authentication request received: {}", requestBody); + + KoboAuthentication auth = new KoboAuthentication(); + auth.setAccessToken(RandomStringUtils.randomAlphanumeric(24)); + auth.setRefreshToken(RandomStringUtils.randomAlphanumeric(24)); + auth.setTrackingId(UUID.randomUUID().toString()); + auth.setUserKey(requestBody.get("UserKey").asText()); + + return ResponseEntity.ok() + .header("Content-Type", "application/json; charset=utf-8") + .header("Content-Length", String.valueOf(toJsonBytes(auth).length)) + .body(auth); + } + + public byte[] toJsonBytes(KoboAuthentication KoboAuthentication) { + try { + return objectMapper.writeValueAsString(KoboAuthentication).getBytes(StandardCharsets.UTF_8); + } catch (JsonProcessingException e) { + log.error("Failed to serialize AuthDto to JSON", e); + throw new RuntimeException("Failed to serialize AuthDto", e); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboInitializationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboInitializationService.java new file mode 100644 index 000000000..b9f3408c5 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboInitializationService.java @@ -0,0 +1,50 @@ +package com.adityachandel.booklore.service.kobo; + +import com.adityachandel.booklore.model.dto.kobo.KoboResources; +import com.adityachandel.booklore.util.kobo.KoboUrlBuilder; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KoboInitializationService { + + private final KoboServerProxy koboServerProxy; + private final KoboResourcesComponent koboResourcesComponent; + private final KoboUrlBuilder koboUrlBuilder; + + public ResponseEntity initialize(String token) throws JsonProcessingException { + JsonNode resources; + + JsonNode body = null; + try { + var response = koboServerProxy.proxyCurrentRequest(null, false); + body = response != null ? response.getBody() : null; + } catch (Exception e) { + log.warn("Failed to get response from Kobo /v1/initialization, fallback to noproxy", e); + } + + resources = (body != null && body.has("Resources")) + ? body.get("Resources") + : koboResourcesComponent.getResources(); + + if (resources instanceof ObjectNode objectNode) { + UriComponentsBuilder baseBuilder = koboUrlBuilder.baseBuilder(); + + objectNode.put("image_host", baseBuilder.build().toUriString()); + objectNode.put("image_url_template", koboUrlBuilder.imageUrlTemplate(token)); + objectNode.put("image_url_quality_template", koboUrlBuilder.imageUrlQualityTemplate(token)); + } + + return ResponseEntity.ok() + .header("x-kobo-apitoken", "e30=") + .body(new KoboResources(resources)); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySyncService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySyncService.java new file mode 100644 index 000000000..449fd6de2 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySyncService.java @@ -0,0 +1,137 @@ +package com.adityachandel.booklore.service.kobo; + +import com.adityachandel.booklore.model.dto.kobo.KoboHeaders; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.BookloreSyncToken; +import com.adityachandel.booklore.model.dto.kobo.*; +import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity; +import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity; +import com.adityachandel.booklore.repository.KoboDeletedBookProgressRepository; +import com.adityachandel.booklore.service.KoboEntitlementService; +import com.adityachandel.booklore.service.KoboLibrarySnapshotService; +import com.adityachandel.booklore.util.RequestUtils; +import com.adityachandel.booklore.util.kobo.BookloreSyncTokenGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.*; +import java.util.stream.Collectors; + +@AllArgsConstructor +@Service +@Slf4j +public class KoboLibrarySyncService { + + private final BookloreSyncTokenGenerator tokenGenerator; + private final KoboLibrarySnapshotService koboLibrarySnapshotService; + private final KoboEntitlementService entitlementService; + private final KoboDeletedBookProgressRepository koboDeletedBookProgressRepository; + private final KoboServerProxy koboServerProxy; + private final ObjectMapper objectMapper; + + public ResponseEntity syncLibrary(BookLoreUser user, String token) { + HttpServletRequest request = RequestUtils.getCurrentRequest(); + BookloreSyncToken syncToken = Optional.ofNullable(tokenGenerator.fromRequestHeaders(request)).orElse(new BookloreSyncToken()); + + KoboLibrarySnapshotEntity currSnapshot = koboLibrarySnapshotService.findByIdAndUserId(syncToken.getOngoingSyncPointId(), user.getId()).orElseGet(() -> koboLibrarySnapshotService.create(user.getId())); + Optional prevSnapshot = koboLibrarySnapshotService.findByIdAndUserId(syncToken.getLastSuccessfulSyncPointId(), user.getId()); + + List entitlements = new ArrayList<>(); + boolean shouldContinueSync = false; + + if (prevSnapshot.isPresent()) { + int maxRemaining = 5; + List addedAll = new ArrayList<>(); + List removedAll = new ArrayList<>(); + + koboLibrarySnapshotService.updateSyncedStatusForExistingBooks(prevSnapshot.get().getId(), currSnapshot.getId()); + + Page addedPage = koboLibrarySnapshotService.getNewlyAddedBooks(prevSnapshot.get().getId(), currSnapshot.getId(), PageRequest.of(0, maxRemaining), user.getId()); + addedAll.addAll(addedPage.getContent()); + maxRemaining -= addedPage.getNumberOfElements(); + shouldContinueSync = addedPage.hasNext(); + + Page removedPage = Page.empty(); + if (addedPage.isLast() && maxRemaining > 0) { + removedPage = koboLibrarySnapshotService.getRemovedBooks(prevSnapshot.get().getId(), currSnapshot.getId(), user.getId(), PageRequest.of(0, maxRemaining)); + removedAll.addAll(removedPage.getContent()); + shouldContinueSync = shouldContinueSync || removedPage.hasNext(); + } + + Set addedIds = addedAll.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet()); + Set removedIds = removedAll.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet()); + + entitlements.addAll(entitlementService.generateNewEntitlements(addedIds, token, false)); + entitlements.addAll(entitlementService.generateChangedEntitlements(removedIds, token, true)); + } else { + int maxRemaining = 5; + List all = new ArrayList<>(); + while (maxRemaining > 0) { + var page = koboLibrarySnapshotService.getUnsyncedBooks(currSnapshot.getId(), PageRequest.of(0, maxRemaining)); + all.addAll(page.getContent()); + maxRemaining -= page.getNumberOfElements(); + shouldContinueSync = page.hasNext(); + if (!shouldContinueSync || page.getNumberOfElements() == 0) break; + } + Set ids = all.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet()); + entitlements.addAll(entitlementService.generateNewEntitlements(ids, token, false)); + } + + if (!shouldContinueSync) { + ResponseEntity koboStoreResponse = koboServerProxy.proxyCurrentRequest(null, true); + Collection syncResultsKobo = Optional.ofNullable(koboStoreResponse.getBody()) + .map(body -> { + try { + List results = new ArrayList<>(); + if (body.isArray()) { + for (JsonNode node : body) { + if (node.has("NewEntitlement")) { + results.add(objectMapper.treeToValue(node, NewEntitlement.class)); + } else if (node.has("ChangedEntitlement")) { + results.add(objectMapper.treeToValue(node, ChangedEntitlement.class)); + } else { + log.warn("Unknown entitlement type in Kobo response: {}", node); + } + } + } + return results; + } catch (Exception e) { + log.error("Failed to map Kobo response to Entitlement objects", e); + return Collections.emptyList(); + } + }) + .orElse(Collections.emptyList()); + + entitlements.addAll(syncResultsKobo); + + shouldContinueSync = "continue".equalsIgnoreCase( + Optional.ofNullable(koboStoreResponse.getHeaders().getFirst(KoboHeaders.X_KOBO_SYNC)).orElse("") + ); + + String koboSyncTokenHeader = koboStoreResponse.getHeaders().getFirst(KoboHeaders.X_KOBO_SYNCTOKEN); + syncToken = koboSyncTokenHeader != null ? tokenGenerator.fromBase64(koboSyncTokenHeader) : syncToken; + } + + if (shouldContinueSync) { + syncToken.setOngoingSyncPointId(currSnapshot.getId()); + } else { + prevSnapshot.ifPresent(sp -> koboLibrarySnapshotService.deleteById(sp.getId())); + koboDeletedBookProgressRepository.deleteBySnapshotIdAndUserId(syncToken.getOngoingSyncPointId(), user.getId()); + syncToken.setOngoingSyncPointId(null); + syncToken.setLastSuccessfulSyncPointId(currSnapshot.getId()); + } + + return ResponseEntity.ok() + .header(KoboHeaders.X_KOBO_SYNC, shouldContinueSync ? "continue" : "") + .header(KoboHeaders.X_KOBO_SYNCTOKEN, tokenGenerator.toBase64(syncToken)) + .body(entitlements); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboResourcesComponent.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboResourcesComponent.java new file mode 100644 index 000000000..449f98077 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboResourcesComponent.java @@ -0,0 +1,184 @@ +package com.adityachandel.booklore.service.kobo; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +@AllArgsConstructor +@Component +public class KoboResourcesComponent { + + private final ObjectMapper objectMapper; + + public JsonNode getResources() throws JsonProcessingException { + return objectMapper.readTree( + """ + { + "account_page": "https://www.kobo.com/account/settings", + "account_page_rakuten": "https://my.rakuten.co.jp/", + "add_device": "https://storeapi.kobo.com/v1/user/add-device", + "add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}", + "affiliaterequest": "https://storeapi.kobo.com/v1/affiliate", + "assets": "https://storeapi.kobo.com/v1/assets", + "audiobook": "https://storeapi.kobo.com/v1/products/audiobooks/{ProductId}", + "audiobook_detail_page": "https://www.kobo.com/{region}/{language}/audiobook/{slug}", + "audiobook_get_credits": "https://www.kobo.com/{region}/{language}/audiobooks/plans", + "audiobook_landing_page": "https://www.kobo.com/{region}/{language}/audiobooks", + "audiobook_preview": "https://storeapi.kobo.com/v1/products/audiobooks/{Id}/preview", + "audiobook_purchase_withcredit": "https://storeapi.kobo.com/v1/store/audiobook/{Id}", + "audiobook_subscription_management": "https://www.kobo.com/{region}/{language}/account/subscriptions", + "audiobook_subscription_orange_deal_inclusion_url": "https://authorize.kobo.com/inclusion", + "audiobook_subscription_purchase": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280", + "audiobook_subscription_tiers": "https://www.kobo.com/{region}/{language}/checkoutoption/21C6D938-934B-4A91-B979-E14D70B2F280", + "authorproduct_recommendations": "https://storeapi.kobo.com/v1/products/books/authors/recommendations", + "autocomplete": "https://storeapi.kobo.com/v1/products/autocomplete", + "blackstone_header": { + "key": "x-amz-request-payer", + "value": "requester" + }, + "book": "https://storeapi.kobo.com/v1/products/books/{ProductId}", + "book_detail_page": "https://www.kobo.com/{region}/{language}/ebook/{slug}", + "book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}", + "book_landing_page": "https://www.kobo.com/ebooks", + "book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions", + "browse_history": "https://storeapi.kobo.com/v1/user/browsehistory", + "categories": "https://storeapi.kobo.com/v1/categories", + "categories_page": "https://www.kobo.com/ebooks/categories", + "category": "https://storeapi.kobo.com/v1/categories/{CategoryId}", + "category_featured_lists": "https://storeapi.kobo.com/v1/categories/{CategoryId}/featured", + "category_products": "https://storeapi.kobo.com/v1/categories/{CategoryId}/products", + "checkout_borrowed_book": "https://storeapi.kobo.com/v1/library/borrow", + "client_authd_referral": "https://authorize.kobo.com/api/AuthenticatedReferral/client/v1/getLink", + "configuration_data": "https://storeapi.kobo.com/v1/configuration", + "content_access_book": "https://storeapi.kobo.com/v1/products/books/{ProductId}/access", + "customer_care_live_chat": "https://v2.zopim.com/widget/livechat.html?key=Y6gwUmnu4OATxN3Tli4Av9bYN319BTdO", + "daily_deal": "https://storeapi.kobo.com/v1/products/dailydeal", + "deals": "https://storeapi.kobo.com/v1/deals", + "delete_entitlement": "https://storeapi.kobo.com/v1/library/{Ids}", + "delete_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}", + "delete_tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/items/delete", + "device_auth": "https://storeapi.kobo.com/v1/auth/device", + "device_refresh": "https://storeapi.kobo.com/v1/auth/refresh", + "dictionary_host": "https://ereaderfiles.kobo.com", + "discovery_host": "https://discovery.kobobooks.com", + "dropbox_link_account_poll": "https://authorize.kobo.com/{region}/{language}/LinkDropbox", + "dropbox_link_account_start": "https://authorize.kobo.com/LinkDropbox/start", + "eula_page": "https://www.kobo.com/termsofuse?style=onestore", + "exchange_auth": "https://storeapi.kobo.com/v1/auth/exchange", + "external_book": "https://storeapi.kobo.com/v1/products/books/external/{Ids}", + "facebook_sso_page": "https://authorize.kobo.com/signin/provider/Facebook/login?returnUrl=http://kobo.com/", + "featured_list": "https://storeapi.kobo.com/v1/products/featured/{FeaturedListId}", + "featured_lists": "https://storeapi.kobo.com/v1/products/featured", + "free_books_page": { + "EN": "https://www.kobo.com/{region}/{language}/p/free-ebooks", + "FR": "https://www.kobo.com/{region}/{language}/p/livres-gratuits", + "IT": "https://www.kobo.com/{region}/{language}/p/libri-gratuiti", + "NL": "https://www.kobo.com/{region}/{language}/List/bekijk-het-overzicht-van-gratis-ebooks/QpkkVWnUw8sxmgjSlCbJRg", + "PT": "https://www.kobo.com/{region}/{language}/p/livros-gratis" + }, + "fte_feedback": "https://storeapi.kobo.com/v1/products/ftefeedback", + "funnel_metrics": "https://storeapi.kobo.com/v1/funnelmetrics", + "get_download_keys": "https://storeapi.kobo.com/v1/library/downloadkeys", + "get_download_link": "https://storeapi.kobo.com/v1/library/downloadlink", + "get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests", + "giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader", + "giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem", + "googledrive_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkcloudstorage/provider/google_drive", + "gpb_flow_enabled": "False", + "help_page": "http://www.kobo.com/help", + "image_host": "//cdn.kobo.com/book-images/", + "image_url_quality_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg", + "image_url_template": "https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg", + "kobo_audiobooks_credit_redemption": "True", + "kobo_audiobooks_enabled": "True", + "kobo_audiobooks_orange_deal_enabled": "True", + "kobo_audiobooks_subscriptions_enabled": "True", + "kobo_display_price": "True", + "kobo_dropbox_link_account_enabled": "True", + "kobo_google_tax": "False", + "kobo_googledrive_link_account_enabled": "True", + "kobo_nativeborrow_enabled": "False", + "kobo_onedrive_link_account_enabled": "False", + "kobo_onestorelibrary_enabled": "False", + "kobo_privacyCentre_url": "https://www.kobo.com/privacy", + "kobo_redeem_enabled": "True", + "kobo_shelfie_enabled": "False", + "kobo_subscriptions_enabled": "True", + "kobo_superpoints_enabled": "False", + "kobo_wishlist_enabled": "True", + "library_book": "https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}", + "library_items": "https://storeapi.kobo.com/v1/user/library", + "library_metadata": "https://storeapi.kobo.com/v1/library/{Ids}/metadata", + "library_prices": "https://storeapi.kobo.com/v1/user/library/previews/prices", + "library_search": "https://storeapi.kobo.com/v1/library/search", + "library_sync": "https://storeapi.kobo.com/v1/library/sync", + "love_dashboard_page": "https://www.kobo.com/{region}/{language}/kobosuperpoints", + "love_points_redemption_page": "https://www.kobo.com/{region}/{language}/KoboSuperPointsRedemption?productId={ProductId}", + "magazine_landing_page": "https://www.kobo.com/emagazines", + "more_sign_in_options": "https://authorize.kobo.com/signin?returnUrl=http://kobo.com/#allProviders", + "notebooks": "https://storeapi.kobo.com/api/internal/notebooks", + "notifications_registration_issue": "https://storeapi.kobo.com/v1/notifications/registration", + "oauth_host": "https://oauth.kobo.com", + "password_retrieval_page": "https://www.kobo.com/passwordretrieval.html", + "personalizedrecommendations": "https://storeapi.kobo.com/v2/users/personalizedrecommendations", + "pocket_link_account_start": "https://authorize.kobo.com/{region}/{language}/linkpocket", + "post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event", + "ppx_purchasing_url": "https://purchasing.kobo.com", + "privacy_page": "https://www.kobo.com/privacypolicy?style=onestore", + "product_nextread": "https://storeapi.kobo.com/v1/products/{ProductIds}/nextread", + "product_prices": "https://storeapi.kobo.com/v1/products/{ProductIds}/prices", + "product_recommendations": "https://storeapi.kobo.com/v1/products/{ProductId}/recommendations", + "product_reviews": "https://storeapi.kobo.com/v1/products/{ProductIds}/reviews", + "products": "https://storeapi.kobo.com/v1/products", + "productsv2": "https://storeapi.kobo.com/v2/products", + "provider_external_sign_in_page": "https://authorize.kobo.com/ExternalSignIn/{providerName}?returnUrl=http://kobo.com/", + "purchase_buy": "https://www.kobo.com/checkoutoption/", + "purchase_buy_templated": "https://www.kobo.com/{region}/{language}/checkoutoption/{ProductId}", + "quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout", + "quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase", + "rakuten_token_exchange": "https://storeapi.kobo.com/v1/auth/rakuten_token_exchange", + "rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}", + "reading_services_host": "https://readingservices.kobo.com", + "reading_state": "https://storeapi.kobo.com/v1/library/{Ids}/state", + "redeem_interstitial_page": "https://www.kobo.com", + "registration_page": "https://authorize.kobo.com/signup?returnUrl=http://kobo.com/", + "related_items": "https://storeapi.kobo.com/v1/products/{Id}/related", + "remaining_book_series": "https://storeapi.kobo.com/v1/products/books/series/{SeriesId}", + "rename_tag": "https://storeapi.kobo.com/v1/library/tags/{TagId}", + "review": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}", + "review_sentiment": "https://storeapi.kobo.com/v1/products/reviews/{ReviewId}/sentiment/{Sentiment}", + "shelfie_recommendations": "https://storeapi.kobo.com/v1/user/recommendations/shelfie", + "sign_in_page": "https://auth.kobobooks.com/ActivateOnWeb", + "social_authorization_host": "https://social.kobobooks.com:8443", + "social_host": "https://social.kobobooks.com", + "store_home": "www.kobo.com/{region}/{language}", + "store_host": "www.kobo.com", + "store_newreleases": "https://www.kobo.com/{region}/{language}/List/new-releases/961XUjtsU0qxkFItWOutGA", + "store_search": "https://www.kobo.com/{region}/{language}/Search?Query={query}", + "store_top50": "https://www.kobo.com/{region}/{language}/ebooks/Top", + "subs_landing_page": "https://www.kobo.com/{region}/{language}/plus", + "subs_management_page": "https://www.kobo.com/{region}/{language}/account/subscriptions", + "subs_purchase_buy_templated": "https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}", + "subscription_publisher_price_page": "https://www.kobo.com/{region}/{language}/subscriptionpublisherprice", + "tag_items": "https://storeapi.kobo.com/v1/library/tags/{TagId}/Items", + "tags": "https://storeapi.kobo.com/v1/library/tags", + "taste_profile": "https://storeapi.kobo.com/v1/products/tasteprofile", + "terms_of_sale_page": "https://authorize.kobo.com/{region}/{language}/terms/termsofsale", + "update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview", + "use_one_store": "True", + "user_loyalty_benefits": "https://storeapi.kobo.com/v1/user/loyalty/benefits", + "user_platform": "https://storeapi.kobo.com/v1/user/platform", + "user_profile": "https://storeapi.kobo.com/v1/user/profile", + "user_ratings": "https://storeapi.kobo.com/v1/user/ratings", + "user_recommendations": "https://storeapi.kobo.com/v1/user/recommendations", + "user_reviews": "https://storeapi.kobo.com/v1/user/reviews", + "user_wishlist": "https://storeapi.kobo.com/v1/user/wishlist", + "userguide_host": "https://ereaderfiles.kobo.com", + "wishlist_page": "https://www.kobo.com/{region}/{language}/account/wishlist" + } + """ + ); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboServerProxy.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboServerProxy.java new file mode 100644 index 000000000..c0b5d482a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboServerProxy.java @@ -0,0 +1,160 @@ +package com.adityachandel.booklore.service.kobo; + +import com.adityachandel.booklore.model.dto.BookloreSyncToken; +import com.adityachandel.booklore.model.dto.kobo.KoboHeaders; +import com.adityachandel.booklore.util.RequestUtils; +import com.adityachandel.booklore.util.kobo.BookloreSyncTokenGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KoboServerProxy { + + private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofMinutes(1)).build(); + private final ObjectMapper objectMapper; + private final BookloreSyncTokenGenerator bookloreSyncTokenGenerator; + + private static final Set HEADERS_OUT_INCLUDE = Set.of( + HttpHeaders.AUTHORIZATION.toLowerCase(), + HttpHeaders.USER_AGENT, + HttpHeaders.ACCEPT, + HttpHeaders.ACCEPT_LANGUAGE + ); + + private static final Set HEADERS_OUT_EXCLUDE = Set.of( + KoboHeaders.X_KOBO_SYNCTOKEN + ); + + private boolean isKoboHeader(String headerName) { + return headerName.toLowerCase().startsWith("x-kobo-"); + } + + public ResponseEntity proxyCurrentRequest(Object body, boolean includeSyncToken) { + HttpServletRequest request = RequestUtils.getCurrentRequest(); + String path = request.getRequestURI().replaceFirst("^/api/kobo/[^/]+", ""); + + BookloreSyncToken syncToken = null; + if (includeSyncToken) { + syncToken = bookloreSyncTokenGenerator.fromRequestHeaders(request); + if (syncToken == null || syncToken.getRawKoboSyncToken() == null || syncToken.getRawKoboSyncToken().isBlank()) { + //throw new IllegalStateException("Request must include sync token, but none found"); + } + } + + return executeProxyRequest(request, body, path, includeSyncToken, syncToken); + } + + public ResponseEntity proxyExternalUrl(String url) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_JPEG); + + return new ResponseEntity<>(new ByteArrayResource(response.body()), headers, HttpStatus.valueOf(response.statusCode())); + } catch (Exception e) { + log.error("Failed to proxy external Kobo CDN URL", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to fetch image", e); + } + } + + private ResponseEntity executeProxyRequest(HttpServletRequest request, Object body, String path, boolean includeSyncToken, BookloreSyncToken syncToken) { + try { + String koboBaseUrl = "https://storeapi.kobo.com"; + + String queryString = request.getQueryString(); + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(koboBaseUrl) + .path(path); + + if (queryString != null && !queryString.isBlank()) { + uriBuilder.query(queryString); + } + + URI uri = uriBuilder.build(true).toUri(); + log.info("Kobo proxy URL: {}", uri); + + String bodyString = body != null ? objectMapper.writeValueAsString(body) : "{}"; + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(uri) + .timeout(Duration.ofMinutes(1)) + .method(request.getMethod(), HttpRequest.BodyPublishers.ofString(bodyString)) + .header(HttpHeaders.CONTENT_TYPE, "application/json"); + + Collections.list(request.getHeaderNames()).forEach(headerName -> { + if (!HEADERS_OUT_EXCLUDE.contains(headerName.toLowerCase()) && + (HEADERS_OUT_INCLUDE.contains(headerName) || isKoboHeader(headerName))) { + Collections.list(request.getHeaders(headerName)) + .forEach(value -> builder.header(headerName, value)); + } + }); + + if (includeSyncToken && syncToken != null && syncToken.getRawKoboSyncToken() != null && !syncToken.getRawKoboSyncToken().isBlank()) { + builder.header(KoboHeaders.X_KOBO_SYNCTOKEN, syncToken.getRawKoboSyncToken()); + } + + HttpRequest httpRequest = builder.build(); + HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + + JsonNode responseBody = response.body() != null && !response.body().isBlank() + ? objectMapper.readTree(response.body()) + : null; + + HttpHeaders responseHeaders = new HttpHeaders(); + response.headers().map().forEach((key, values) -> { + if (isKoboHeader(key)) { + responseHeaders.put(key, values); + } + }); + + if (responseHeaders.containsKey(KoboHeaders.X_KOBO_SYNCTOKEN) && includeSyncToken && syncToken != null) { + String koboToken = responseHeaders.getFirst(KoboHeaders.X_KOBO_SYNCTOKEN); + if (koboToken != null) { + BookloreSyncToken updated = BookloreSyncToken.builder() + .ongoingSyncPointId(syncToken.getOngoingSyncPointId()) + .lastSuccessfulSyncPointId(syncToken.getLastSuccessfulSyncPointId()) + .rawKoboSyncToken(koboToken) + .build(); + responseHeaders.set(KoboHeaders.X_KOBO_SYNCTOKEN, bookloreSyncTokenGenerator.toBase64(updated)); + } + } + + log.info("Kobo proxy response status: {}", response.statusCode()); + + return new ResponseEntity<>(responseBody, responseHeaders, HttpStatus.valueOf(response.statusCode())); + + } catch (Exception e) { + log.error("Failed to proxy request to Kobo", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to proxy request to Kobo", e); + } + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboThumbnailService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboThumbnailService.java new file mode 100644 index 000000000..42ba2a2b6 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboThumbnailService.java @@ -0,0 +1,38 @@ +package com.adityachandel.booklore.service.kobo; + +import com.adityachandel.booklore.service.BookService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KoboThumbnailService { + + private final BookService bookService; + + public ResponseEntity getThumbnail(Long bookId) { + return getThumbnailInternal(bookId); + } + + private ResponseEntity getThumbnailInternal(Long bookId) { + + Resource image = bookService.getBookCover(bookId); + if (!isValidImage(image)) { + log.warn("Thumbnail not found for bookId={}", bookId); + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_TYPE, "image/jpeg") + .body(image); + } + + private boolean isValidImage(Resource image) { + return image != null && image.exists(); + } +} \ No newline at end of file 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 422a325c2..23718975d 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 @@ -54,6 +54,7 @@ public class UserProvisioningService { perms.setPermissionEmailBook(true); perms.setPermissionDeleteBook(true); perms.setPermissionSyncKoreader(true); + perms.setPermissionSyncKobo(true); user.setPermissions(perms); createUser(user); @@ -83,6 +84,7 @@ public class UserProvisioningService { permissions.setPermissionEmailBook(request.isPermissionEmailBook()); permissions.setPermissionDeleteBook(request.isPermissionDeleteBook()); permissions.setPermissionSyncKoreader(request.isPermissionSyncKoreader()); + permissions.setPermissionSyncKobo(request.isPermissionSyncKobo()); permissions.setPermissionAdmin(request.isPermissionAdmin()); user.setPermissions(permissions); @@ -114,6 +116,7 @@ public class UserProvisioningService { perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook")); perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook")); perms.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader")); + perms.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo")); } user.setPermissions(perms); @@ -161,15 +164,17 @@ public class UserProvisioningService { permissions.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary")); permissions.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook")); permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook")); - permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionSyncKoreader")); + permissions.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader")); + permissions.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo")); } else { - permissions.setPermissionUpload(true); - permissions.setPermissionDownload(true); - permissions.setPermissionEditMetadata(true); + permissions.setPermissionUpload(false); + permissions.setPermissionDownload(false); + permissions.setPermissionEditMetadata(false); permissions.setPermissionManipulateLibrary(false); - permissions.setPermissionEmailBook(true); - permissions.setPermissionDeleteBook(true); - permissions.setPermissionSyncKoreader(true); + permissions.setPermissionEmailBook(false); + permissions.setPermissionDeleteBook(false); + permissions.setPermissionSyncKoreader(false); + permissions.setPermissionSyncKobo(false); } permissions.setPermissionAdmin(isAdmin); 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 aa8e3f529..e2649f319 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 @@ -54,6 +54,7 @@ public class UserService { user.getPermissions().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook()); user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook()); user.getPermissions().setPermissionSyncKoreader(updateRequest.getPermissions().isCanSyncKoReader()); + user.getPermissions().setPermissionSyncKobo(updateRequest.getPermissions().isCanSyncKobo()); } if (updateRequest.getAssignedLibraries() != null && getMyself().getPermissions().isAdmin()) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/RequestUtils.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/RequestUtils.java new file mode 100644 index 000000000..0c007b6ee --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/RequestUtils.java @@ -0,0 +1,20 @@ +package com.adityachandel.booklore.util; + + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +public final class RequestUtils { + + private RequestUtils() { + } + + public static HttpServletRequest getCurrentRequest() { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + throw new IllegalStateException("No current HTTP request found"); + } + return attrs.getRequest(); + } +} 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 d6b8b7f89..965ddc55f 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 @@ -14,6 +14,7 @@ public class UserPermissionUtils { case EMAIL_BOOK -> perms.isPermissionEmailBook(); case DELETE_BOOK -> perms.isPermissionDeleteBook(); case SYNC_KOREADER -> perms.isPermissionSyncKoreader(); + case SYNC_KOBO -> perms.isPermissionSyncKobo(); case ADMIN -> perms.isPermissionAdmin(); }; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/kobo/BookloreSyncTokenGenerator.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/kobo/BookloreSyncTokenGenerator.java new file mode 100644 index 000000000..72a33fdae --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/kobo/BookloreSyncTokenGenerator.java @@ -0,0 +1,56 @@ +package com.adityachandel.booklore.util.kobo; + + +import com.adityachandel.booklore.model.dto.kobo.KoboHeaders; +import com.adityachandel.booklore.model.dto.BookloreSyncToken; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Base64; + +@Slf4j +@RequiredArgsConstructor +@Component +public class BookloreSyncTokenGenerator { + + private static final String BOOKLORE_TOKEN_PREFIX = "BOOKLORE."; + + private final ObjectMapper objectMapper; + private final Base64.Encoder base64Encoder = Base64.getEncoder().withoutPadding(); + private final Base64.Decoder base64Decoder = Base64.getDecoder(); + + public BookloreSyncToken fromBase64(String base64Token) { + try { + if (base64Token.startsWith(BOOKLORE_TOKEN_PREFIX)) { + byte[] decoded = base64Decoder.decode(base64Token.substring(BOOKLORE_TOKEN_PREFIX.length())); + return objectMapper.readValue(decoded, BookloreSyncToken.class); + } + if (base64Token.contains(".")) { + return BookloreSyncToken.builder() + .rawKoboSyncToken(base64Token) + .build(); + } + } catch (Exception ignored) { + + } + return new BookloreSyncToken(); + } + + public String toBase64(BookloreSyncToken token) { + try { + String json = objectMapper.writeValueAsString(token); + return BOOKLORE_TOKEN_PREFIX + base64Encoder.encodeToString(json.getBytes()); + } catch (Exception e) { + log.error("Failed to serialize Booklore sync token", e); + return BOOKLORE_TOKEN_PREFIX; + } + } + + public BookloreSyncToken fromRequestHeaders(HttpServletRequest request) { + String tokenB64 = request.getHeader(KoboHeaders.X_KOBO_SYNCTOKEN); + return tokenB64 != null ? fromBase64(tokenB64) : null; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/kobo/KoboUrlBuilder.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/kobo/KoboUrlBuilder.java new file mode 100644 index 000000000..c926e9c32 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/kobo/KoboUrlBuilder.java @@ -0,0 +1,64 @@ +package com.adityachandel.booklore.util.kobo; + +import com.adityachandel.booklore.util.RequestUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +@Slf4j +public class KoboUrlBuilder { + + public UriComponentsBuilder baseBuilder() { + HttpServletRequest request = RequestUtils.getCurrentRequest(); + + UriComponentsBuilder builder = ServletUriComponentsBuilder + .fromCurrentContextPath() + .replacePath("") + .replaceQuery(null) + .port(-1); // drop default port + + String host = builder.build().getHost(); + String scheme = builder.build().getScheme(); + + if (host == null) host = ""; + + String xfPort = request.getHeader("X-Forwarded-Port"); + try { + int port = Integer.parseInt(xfPort); + + if (host.matches("\\d+\\.\\d+\\.\\d+\\.\\d+") || "localhost".equals(host)) { + builder.port(port); + } + log.info("Applied X-Forwarded-Port: {}", port); + } catch (NumberFormatException e) { + log.warn("Invalid X-Forwarded-Port header: {}", xfPort); + } + + log.info("Final base URL: {}", builder.build().toUriString()); + return builder; + } + + public String downloadUrl(String token, Long bookId) { + return baseBuilder() + .pathSegment("api", "kobo", token, "v1", "books", "{bookId}", "download") + .buildAndExpand(bookId) + .toUriString(); + } + + public String imageUrlTemplate(String token) { + return baseBuilder() + .pathSegment("api", "kobo", token, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "false", "image.jpg") + .build() + .toUriString(); + } + + public String imageUrlQualityTemplate(String token) { + return baseBuilder() + .pathSegment("api", "kobo", token, "v1", "books", "{ImageId}", "thumbnail", "{Width}", "{Height}", "{Quality}", "{IsGreyscale}", "image.jpg") + .build() + .toUriString(); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/resources/application.yaml b/booklore-api/src/main/resources/application.yaml index 4ab77b722..39129e950 100644 --- a/booklore-api/src/main/resources/application.yaml +++ b/booklore-api/src/main/resources/application.yaml @@ -13,6 +13,9 @@ app: header-groups: ${REMOTE_AUTH_HEADER_GROUPS:Remote-Groups} admin-group: ${REMOTE_AUTH_ADMIN_GROUP} +server: + forward-headers-strategy: native + spring: servlet: multipart: diff --git a/booklore-api/src/main/resources/db/migration/V48__Create_kobo_tables.sql b/booklore-api/src/main/resources/db/migration/V48__Create_kobo_tables.sql new file mode 100644 index 000000000..20ac19a6e --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V48__Create_kobo_tables.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS kobo_user_settings +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL UNIQUE, + token VARCHAR(2048) NOT NULL, + sync_enabled BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS kobo_library_snapshot +( + id VARCHAR(36) PRIMARY KEY, + user_id BIGINT NOT NULL, + created_date TIMESTAMP NOT NULL, + CONSTRAINT fk_snapshot_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS kobo_library_snapshot_book +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + snapshot_id VARCHAR(36) NOT NULL, + book_id BIGINT NOT NULL, + synced BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_snapshot_book FOREIGN KEY (snapshot_id) REFERENCES kobo_library_snapshot (id) ON DELETE CASCADE, + CONSTRAINT uq_snapshot_book UNIQUE (snapshot_id, book_id) +); + +CREATE TABLE IF NOT EXISTS kobo_reading_state +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + entitlement_id VARCHAR(255) NOT NULL UNIQUE, + created VARCHAR(255) NULL, + last_modified VARCHAR(255) NULL, + priority_timestamp VARCHAR(255) NULL, + current_bookmark_json JSON, + statistics_json JSON, + status_info_json JSON, + last_modified_string VARCHAR(255) +); + +CREATE TABLE IF NOT EXISTS kobo_removed_books_tracking +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + snapshot_id VARCHAR(36) NOT NULL, + user_id BIGINT NOT NULL, + book_id_synced BIGINT NOT NULL, + CONSTRAINT uq_snapshot_user_book UNIQUE (snapshot_id, user_id, book_id_synced), + CONSTRAINT fk_removed_snapshot FOREIGN KEY (snapshot_id) REFERENCES kobo_library_snapshot (id) ON DELETE CASCADE +); + +ALTER TABLE user_permissions + ADD COLUMN IF NOT EXISTS permission_sync_kobo BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE user_permissions +SET permission_sync_kobo = TRUE +WHERE permission_admin = TRUE; \ 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 9f75f7e8d..0601a96be 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 @@ -39,7 +39,8 @@ export class AuthenticationSettingsComponent implements OnInit { {label: 'Manage Library', value: 'permissionManipulateLibrary', selected: false}, {label: 'Email Book', value: 'permissionEmailBook', selected: false}, {label: 'Delete Book', value: 'permissionDeleteBook', selected: false}, - {label: 'KOReader Sync', value: 'permissionSyncKoreader', selected: false} + {label: 'KOReader Sync', value: 'permissionSyncKoreader', selected: false}, + {label: 'Kobo Sync', value: 'permissionSyncKobo', selected: false} ]; internalAuthEnabled = true; diff --git a/booklore-ui/src/app/dashboard/components/dashboard-scroller/dashboard-scroller.component.scss b/booklore-ui/src/app/dashboard/components/dashboard-scroller/dashboard-scroller.component.scss index 15c255ce9..65d180463 100644 --- a/booklore-ui/src/app/dashboard/components/dashboard-scroller/dashboard-scroller.component.scss +++ b/booklore-ui/src/app/dashboard/components/dashboard-scroller/dashboard-scroller.component.scss @@ -147,35 +147,11 @@ } @media (max-width: 767px) { - .dashboard-scroller-container { - padding: 1.5rem; - margin-bottom: 2rem; - border-radius: 16px; - } - - .dashboard-scroller-title { - font-size: 1.75rem; - margin-bottom: 1rem; - - &::after { - width: 50px; - height: 3px; - } - } .dashboard-scroller-card { - height: 230px; - width: 135px; - flex-basis: 135px; - } - - .dashboard-scroller-infinite { - gap: 1.5rem; - padding: 0.75rem 0.25rem 1.5rem 0.25rem; - - &::-webkit-scrollbar { - height: 8px; - } + height: 184px; /* 80% of 230px */ + width: 108px; /* 80% of 135px */ + flex-basis: 108px; } .dashboard-scroller-no-books { diff --git a/booklore-ui/src/app/layout/component/layout-menu/app.menu.component.ts b/booklore-ui/src/app/layout/component/layout-menu/app.menu.component.ts index 04507f58d..532d677c0 100644 --- a/booklore-ui/src/app/layout/component/layout-menu/app.menu.component.ts +++ b/booklore-ui/src/app/layout/component/layout-menu/app.menu.component.ts @@ -138,6 +138,12 @@ export class AppMenuComponent implements OnInit { const shelves = state.shelves ?? []; const sortedShelves = this.sortArray(shelves, this.shelfSortField, this.shelfSortOrder); + const koboShelfIndex = sortedShelves.findIndex(shelf => shelf.name === 'Kobo'); + let koboShelf = null; + if (koboShelfIndex !== -1) { + koboShelf = sortedShelves.splice(koboShelfIndex, 1)[0]; + } + const shelfItems = sortedShelves.map((shelf) => ({ menu: this.libraryShelfMenuService.initializeShelfMenuItems(shelf), label: shelf.name, @@ -155,13 +161,25 @@ export class AppMenuComponent implements OnInit { bookCount$: this.shelfService.getUnshelvedBookCount?.() ?? of(0), }; + const items = [unshelvedItem]; + if (koboShelf) { + items.push({ + label: koboShelf.name, + type: 'Shelf', + icon: 'pi pi-' + koboShelf.icon, + routerLink: [`/shelf/${koboShelf.id}/books`], + bookCount$: this.shelfService.getBookCount(koboShelf.id ?? 0), + }); + } + items.push(...shelfItems); + return [ { type: 'shelf', label: 'Shelves', hasDropDown: true, hasCreate: false, - items: [unshelvedItem, ...shelfItems], + items, }, ]; }) diff --git a/booklore-ui/src/app/layout/component/layout-menu/app.menuitem.component.html b/booklore-ui/src/app/layout/component/layout-menu/app.menuitem.component.html index 1fa0eaea3..4e32d423b 100644 --- a/booklore-ui/src/app/layout/component/layout-menu/app.menuitem.component.html +++ b/booklore-ui/src/app/layout/component/layout-menu/app.menuitem.component.html @@ -51,7 +51,7 @@ } @if (item.type === 'Library' || item.type === 'Shelf' || item.type === 'magicShelfItem') { - @if ((item.type !== 'Library' || (admin || canManipulateLibrary)) && item.label !== 'Unshelved') { + @if ((item.type !== 'Library' || (admin || canManipulateLibrary)) && item.label !== 'Unshelved' && item.label !== 'Kobo') { } - @if (item.type === 'Library' && (!admin && !canManipulateLibrary) || item.type === 'All Books' || item.label === 'Unshelved') { + @if (item.type === 'Library' && (!admin && !canManipulateLibrary) || item.type === 'All Books' || item.label === 'Unshelved' || item.label === 'Kobo') {

{{ (item.bookCount$ | async)?.toString() || '0' }}

diff --git a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html index 965841d52..20032a70f 100644 --- a/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/metadata/book-metadata-center-component/metadata-viewer/metadata-viewer.component.html @@ -226,7 +226,7 @@ }
-
+

Publisher: @if (book?.metadata?.publisher) { diff --git a/booklore-ui/src/app/settings/device-settings-component/device-settings-component.html b/booklore-ui/src/app/settings/device-settings-component/device-settings-component.html index 29e04f166..6e1b020f4 100644 --- a/booklore-ui/src/app/settings/device-settings-component/device-settings-component.html +++ b/booklore-ui/src/app/settings/device-settings-component/device-settings-component.html @@ -2,4 +2,9 @@

+ +
+ +
+
diff --git a/booklore-ui/src/app/settings/device-settings-component/device-settings-component.ts b/booklore-ui/src/app/settings/device-settings-component/device-settings-component.ts index 3a42c68b9..a9c627cd0 100644 --- a/booklore-ui/src/app/settings/device-settings-component/device-settings-component.ts +++ b/booklore-ui/src/app/settings/device-settings-component/device-settings-component.ts @@ -1,10 +1,14 @@ import {Component} from '@angular/core'; -import {KoreaderSettingsComponent} from '../koreader-settings-component/koreader-settings-component'; +import {KoreaderSettingsComponent} from './koreader-settings-component/koreader-settings-component'; +import {KoboSyncSettingsComponent} from './kobo-sync-settings-component/kobo-sync-settings-component'; +import {Divider} from 'primeng/divider'; @Component({ selector: 'app-device-settings-component', imports: [ - KoreaderSettingsComponent + KoreaderSettingsComponent, + KoboSyncSettingsComponent, + Divider ], templateUrl: './device-settings-component.html', styleUrl: './device-settings-component.scss' diff --git a/booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.html b/booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.html new file mode 100644 index 000000000..a6715ec2f --- /dev/null +++ b/booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.html @@ -0,0 +1,62 @@ + + +

+ Kobo Sync Settings: + + +

+ +@if (hasPermission) { +
+
+
+ +
+ + + + + +
+
+ + + +
+
+} @else { +
+ + + Access to Kobo sync is restricted. +
+ Please contact your administrator to request permission. +
+
+} diff --git a/booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.scss b/booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.scss similarity index 100% rename from booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.scss rename to booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.scss diff --git a/booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.ts b/booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.ts new file mode 100644 index 000000000..357df11ac --- /dev/null +++ b/booklore-ui/src/app/settings/device-settings-component/kobo-sync-settings-component/kobo-sync-settings-component.ts @@ -0,0 +1,95 @@ +import {Component, inject, OnDestroy, OnInit} from '@angular/core'; +import {ConfirmationService, MessageService} from 'primeng/api'; +import {Clipboard} from '@angular/cdk/clipboard'; +import {KoboService, KoboSyncSettings} from '../kobo.service'; +import {FormsModule} from '@angular/forms'; +import {Button} from 'primeng/button'; +import {InputText} from 'primeng/inputtext'; +import {ConfirmDialog} from 'primeng/confirmdialog'; +import {UserService} from '../../user-management/user.service'; +import {Subject} from 'rxjs'; +import {filter, takeUntil} from 'rxjs/operators'; +import {Tooltip} from 'primeng/tooltip'; + +@Component({ + selector: 'app-kobo-sync-setting-component', + standalone: true, + templateUrl: './kobo-sync-settings-component.html', + styleUrl: './kobo-sync-settings-component.scss', + imports: [FormsModule, Button, InputText, ConfirmDialog, Tooltip], + providers: [MessageService, ConfirmationService] +}) +export class KoboSyncSettingsComponent implements OnInit, OnDestroy { + private koboService = inject(KoboService); + private messageService = inject(MessageService); + private confirmationService = inject(ConfirmationService); + private clipboard = inject(Clipboard); + protected userService = inject(UserService); + + private readonly destroy$ = new Subject(); + hasPermission = false; + + koboToken = ''; + credentialsSaved = false; + showToken = false; + + ngOnInit() { + this.userService.userState$.pipe( + filter(userState => !!userState?.user && userState.loaded), + takeUntil(this.destroy$) + ).subscribe(userState => { + this.hasPermission = (userState.user?.permissions.canSyncKobo || userState.user?.permissions.admin) ?? false; + if (this.hasPermission) { + this.koboService.getUser().subscribe({ + next: (settings: KoboSyncSettings) => { + this.koboToken = settings.token; + this.credentialsSaved = !!settings.token; + }, + error: () => { + this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to load Kobo settings'}); + } + }); + } + }); + } + + copyText(text: string) { + this.clipboard.copy(text); + this.messageService.add({severity: 'success', summary: 'Copied', detail: 'Token copied to clipboard'}); + } + + toggleShowToken() { + this.showToken = !this.showToken; + } + + confirmRegenerateToken() { + this.confirmationService.confirm({ + message: 'This will generate a new token and invalidate the previous one. Continue?', + header: 'Confirm Regeneration', + icon: 'pi pi-exclamation-triangle', + accept: () => this.regenerateToken() + }); + } + + private regenerateToken() { + this.koboService.createOrUpdateToken().subscribe({ + next: (settings) => { + this.koboToken = settings.token; + this.credentialsSaved = true; + this.messageService.add({severity: 'success', summary: 'Token regenerated', detail: 'New token generated successfully'}); + }, + error: () => { + this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to regenerate token'}); + } + }); + } + + openKoboDocumentation(): void { + window.open('https://booklore-app.github.io/booklore-docs/docs/devices/kobo', '_blank'); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/booklore-ui/src/app/settings/device-settings-component/kobo.service.ts b/booklore-ui/src/app/settings/device-settings-component/kobo.service.ts new file mode 100644 index 000000000..0eeec595b --- /dev/null +++ b/booklore-ui/src/app/settings/device-settings-component/kobo.service.ts @@ -0,0 +1,24 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {API_CONFIG} from '../../config/api-config'; + +export interface KoboSyncSettings { + token: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class KoboService { + private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v1/kobo-settings`; + private readonly http = inject(HttpClient); + + createOrUpdateToken(): Observable { + return this.http.put(`${this.baseUrl}`, null); + } + + getUser(): Observable { + return this.http.get(`${this.baseUrl}`); + } +} diff --git a/booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.html b/booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.html new file mode 100644 index 000000000..26b68a873 --- /dev/null +++ b/booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.html @@ -0,0 +1,128 @@ + + +

+ KOReader Sync Settings: + + +

+ +@if (hasPermission) { +
+ +
+ + + +
+ +
+ +
+ +
+ + + +
+
+ + +
+ +
+ + + +
+ + Username is required. + +
+ + +
+ +
+ + + + + +
+ + Password must be at least 6 characters. + +
+ + + +
+
+} @else { +
+ + + Access to KOReader sync is restricted. +
+ Please contact your administrator to request permission. +
+
+} diff --git a/booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.scss b/booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.ts b/booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.ts similarity index 67% rename from booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.ts rename to booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.ts index e29e54af1..7e76a467a 100644 --- a/booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.ts +++ b/booklore-ui/src/app/settings/device-settings-component/koreader-settings-component/koreader-settings-component.ts @@ -1,4 +1,4 @@ -import {Component, inject, OnInit} from '@angular/core'; +import {Component, inject, OnDestroy, OnInit} from '@angular/core'; import {CommonModule} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {InputText} from 'primeng/inputtext'; @@ -6,7 +6,11 @@ import {ToggleSwitch} from 'primeng/toggleswitch'; import {Button} from 'primeng/button'; import {ToastModule} from 'primeng/toast'; import {MessageService} from 'primeng/api'; -import {KoreaderService} from '../../koreader-service'; +import {KoreaderService} from '../koreader.service'; +import {UserService} from '../../user-management/user.service'; +import {filter, takeUntil} from 'rxjs/operators'; +import {Subject} from 'rxjs'; +import {Tooltip} from 'primeng/tooltip'; @Component({ standalone: true, @@ -17,13 +21,14 @@ import {KoreaderService} from '../../koreader-service'; InputText, ToggleSwitch, Button, - ToastModule + ToastModule, + Tooltip ], providers: [MessageService], templateUrl: './koreader-settings-component.html', styleUrls: ['./koreader-settings-component.scss'] }) -export class KoreaderSettingsComponent implements OnInit { +export class KoreaderSettingsComponent implements OnInit, OnDestroy { editMode = true; showPassword = false; koReaderSyncEnabled = false; @@ -34,8 +39,24 @@ export class KoreaderSettingsComponent implements OnInit { private readonly messageService = inject(MessageService); private readonly koreaderService = inject(KoreaderService); + private readonly userService = inject(UserService); + + private readonly destroy$ = new Subject(); + hasPermission = false; ngOnInit() { + this.userService.userState$.pipe( + filter(userState => !!userState?.user && userState.loaded), + takeUntil(this.destroy$) + ).subscribe(userState => { + this.hasPermission = (userState.user?.permissions.canSyncKoReader || userState.user?.permissions.admin) ?? false; + if (this.hasPermission) { + this.loadKoreaderSettings(); + } + }); + } + + private loadKoreaderSettings() { this.koreaderService.getUser().subscribe({ next: koreaderUser => { this.koReaderUsername = koreaderUser.username; @@ -44,10 +65,12 @@ export class KoreaderSettingsComponent implements OnInit { this.credentialsSaved = true; }, error: err => { - if (err.status === 404) { - this.messageService.add({severity: 'warn', summary: 'User Not Found', detail: 'No KOReader account found. Please create one to enable sync.', life: 5000}); - } else { - this.messageService.add({severity: 'error', summary: 'Load Error', detail: 'Unable to retrieve KOReader account. Please try again.'}); + if (err.status !== 404) { + this.messageService.add({ + severity: 'error', + summary: 'Load Error', + detail: 'Unable to retrieve KOReader account. Please try again.' + }); } } }); @@ -102,4 +125,13 @@ export class KoreaderSettingsComponent implements OnInit { console.error('Copy failed', err); }); } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + openKoReaderDocumentation() { + window.open('https://booklore-app.github.io/booklore-docs/docs/devices/koreader', '_blank'); + } } diff --git a/booklore-ui/src/app/koreader-service.ts b/booklore-ui/src/app/settings/device-settings-component/koreader.service.ts similarity index 94% rename from booklore-ui/src/app/koreader-service.ts rename to booklore-ui/src/app/settings/device-settings-component/koreader.service.ts index 664acf6ff..20a928700 100644 --- a/booklore-ui/src/app/koreader-service.ts +++ b/booklore-ui/src/app/settings/device-settings-component/koreader.service.ts @@ -1,6 +1,6 @@ import {inject, Injectable} from '@angular/core'; import {Observable} from 'rxjs'; -import {API_CONFIG} from './config/api-config'; +import {API_CONFIG} from '../../config/api-config'; import {HttpClient} from '@angular/common/http'; diff --git a/booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.html b/booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.html deleted file mode 100644 index 35f6a1650..000000000 --- a/booklore-ui/src/app/settings/koreader-settings-component/koreader-settings-component.html +++ /dev/null @@ -1,113 +0,0 @@ - -

- KOReader Settings: -

- -
- -
- - - -
- - -
- -
- -
- - - -
-
- -
- -
- - - -
- @if (editMode && usernameModel.invalid && usernameModel.touched) { - - Username is required. - - } -
- -
- -
- - - - - -
- @if (editMode && passwordModel.invalid && passwordModel.touched) { - - Password must be at least 6 characters. - - } -
- - - -
-
diff --git a/booklore-ui/src/app/settings/settings.component.html b/booklore-ui/src/app/settings/settings.component.html index 962c49812..e49c1da1c 100644 --- a/booklore-ui/src/app/settings/settings.component.html +++ b/booklore-ui/src/app/settings/settings.component.html @@ -32,11 +32,9 @@ OPDS } - @if (userState.user.permissions.admin || userState.user.permissions.canSyncKoReader) { - - Devices - - } + + Devices + @@ -68,11 +66,9 @@ } - @if (userState.user.permissions.admin || userState.user.permissions.canSyncKoReader) { - - - - } + + +
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 00c9763cd..989e40250 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 @@ -96,6 +96,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 d16b214cf..9ebcb84a6 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 @@ -51,6 +51,7 @@ export class CreateUserDialogComponent implements OnInit { permissionEmailBook: [false], permissionDeleteBook: [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 c97a028ba..7ff6a4d49 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 @@ -23,6 +23,7 @@ Email Books Delete Books KOReader Sync + Kobo Sync Edit Change Password Delete @@ -31,11 +32,13 @@ - + {{ (user.provisioningMethod || 'LOCAL') | lowercase | titlecase }} - {{ user.username }} - + + {{ user.username }} + + @if (user.isEditing) { } @@ -133,6 +136,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 02b41fb7f..9fa06f960 100644 --- a/booklore-ui/src/app/settings/user-management/user.service.ts +++ b/booklore-ui/src/app/settings/user-management/user.service.ts @@ -106,6 +106,7 @@ export interface User { canEditMetadata: boolean; canManipulateLibrary: boolean; canSyncKoReader: boolean; + canSyncKobo: boolean; }; userSettings: UserSettings; provisioningMethod?: 'LOCAL' | 'OIDC' | 'REMOTE'; diff --git a/booklore-ui/src/assets/layout/styles/layout/_menu.scss b/booklore-ui/src/assets/layout/styles/layout/_menu.scss index fdddd1130..19a6886ac 100644 --- a/booklore-ui/src/assets/layout/styles/layout/_menu.scss +++ b/booklore-ui/src/assets/layout/styles/layout/_menu.scss @@ -76,7 +76,7 @@ outline: 0 none; color: var(--text-color); cursor: pointer; - padding: 0.6rem 0.4rem 0.6rem 0.75rem; + padding: 0.45rem 0.3rem 0.45rem 0.6rem; border-radius: variables.$borderRadius; transition: background-color variables.$transitionDuration, box-shadow variables.$transitionDuration; diff --git a/nginx.conf b/nginx.conf index ab22582a3..26fd17da1 100644 --- a/nginx.conf +++ b/nginx.conf @@ -9,7 +9,7 @@ http { client_max_body_size 1000M; server { - listen 6060; # Listen on port 6060 for both API and UI + listen ${BOOKLORE_PORT}; # Set the root directory for the server (Angular app) root /usr/share/nginx/html; @@ -33,11 +33,9 @@ http { # Proxy API requests that start with /api/ to the backend location /api/ { - proxy_pass http://localhost:8080; # Backend API running on port 8080 - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://localhost:8080; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Host $host; } # Proxy WebSocket requests (ws://) to the backend diff --git a/start.sh b/start.sh new file mode 100644 index 000000000..36e3ab402 --- /dev/null +++ b/start.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# Set default and export so envsubst sees it +: "${BOOKLORE_PORT:=6060}" +export BOOKLORE_PORT + +# Use envsubst safely +TMP_CONF="/tmp/nginx.conf.tmp" +envsubst '${BOOKLORE_PORT}' < /etc/nginx/nginx.conf > "$TMP_CONF" + +# Move to final location +mv "$TMP_CONF" /etc/nginx/nginx.conf + +# Start nginx in background +nginx -g 'daemon off;' & + +# Start Spring Boot in foreground +exec java -jar /app/app.jar \ No newline at end of file