diff --git a/.github/workflows/symfony.yml b/.github/workflows/symfony.yml index e0cc84e5..954d89b8 100644 --- a/.github/workflows/symfony.yml +++ b/.github/workflows/symfony.yml @@ -5,7 +5,10 @@ name: Tests -on: [push,pull_request] +on: + push: + pull_request: + types: [opened, reopened] permissions: contents: read diff --git a/composer.json b/composer.json index 644a2935..8715666c 100644 --- a/composer.json +++ b/composer.json @@ -135,6 +135,10 @@ "Composer\\Config::disableProcessTimeout", "env XDEBUG_MODE=off ./vendor/bin/phpunit --colors=always" ], + "test-phpunit-xdebug": [ + "Composer\\Config::disableProcessTimeout", + "env XDEBUG_MODE=debug XDEBUG_TRIGGER=1 ./vendor/bin/phpunit --colors=always" + ], "test": [ "@test-phpcs", "@test-phpstan", diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 19323130..c4ecd72c 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -1,8 +1,10 @@ monolog: channels: - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists - - proxy # Proxy logs are logged in the dedicated "proxy" channel when it exists - - kobo + - kobo_proxy # Proxy logs are logged in the dedicated "proxy" channel when it exists + - kobo_http # All request done on AbstractKoboController + - kobo_sync # Log specific to Sync endpoint + - kobo_kepubify # kepub conversion handlers: @@ -16,10 +18,10 @@ monolog: type: console process_psr_3_messages: false channels: ["!event", "!doctrine"] - proxy: + kobo: type: rotating_file level: debug - channels: ["proxy","kobo"] + channels: ["kobo_proxy","kobo_http", "kobo_sync", "kobo_kepubify"] path: "%kernel.logs_dir%/kobo.%kernel.environment%.log" max_files: 10 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index e1381943..b004afdf 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -50,7 +50,7 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - - { path: ^/api/v3/content/checkforchanges, roles: PUBLIC_ACCESS } + - { path: ^/api/v3/content/, roles: PUBLIC_ACCESS } - { path: ^/login, roles: PUBLIC_ACCESS } - { path: ^/logout, roles: ROLE_USER } - { path: ^/, roles: ROLE_USER } diff --git a/config/services.yaml b/config/services.yaml index 5a58c8d4..d29ee4a7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -16,6 +16,8 @@ parameters: KOBO_IMAGE_API_URL: '%env(KOBO_IMAGE_API_URL)%' env(KEPUBIFY_BIN): "/usr/bin/kepubify" KEPUBIFY_BIN: '%env(KEPUBIFY_BIN)%' + KOBO_READINGSERVICES_URL: '%env(bool:KOBO_READINGSERVICES_URL)%' + env(KOBO_READINGSERVICES_URL): 'https://readingservices.kobo.com' services: # default configuration for services in *this* file _defaults: @@ -98,6 +100,7 @@ services: App\Kobo\Proxy\KoboProxyConfiguration: calls: - [ setStoreApiUrl, ['%KOBO_API_URL%'] ] + - [ setReadingServiceUrl, ['%KOBO_READINGSERVICES_URL%'] ] - [ setImageApiUrl, ['%KOBO_IMAGE_API_URL%'] ] - [ setEnabled, ['%KOBO_PROXY_ENABLED%'] ] - [ setUseProxyEverywhere, ['%KOBO_PROXY_USE_EVERYWHERE%'] ] @@ -110,6 +113,10 @@ services: tags: - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 101 } + App\Kobo\LogProcessor\KoboContextProcessor: + tags: + - { name: monolog.processor } + when@dev: services: Symfony\Component\HttpKernel\Profiler\Profiler: '@profiler' \ No newline at end of file diff --git a/data/database.sqlite-journal b/data/database.sqlite-journal new file mode 100644 index 00000000..f5bd66ab Binary files /dev/null and b/data/database.sqlite-journal differ diff --git a/migrations/Version20241129185216.php b/migrations/Version20241129185216.php new file mode 100644 index 00000000..155dc80b --- /dev/null +++ b/migrations/Version20241129185216.php @@ -0,0 +1,29 @@ +addSql('ALTER TABLE kobo_device ADD upstream_sync TINYINT(1) DEFAULT 0 NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE kobo_device DROP upstream_sync'); + } +} diff --git a/src/Controller/Kobo/AbstractKoboController.php b/src/Controller/Kobo/AbstractKoboController.php index 13c5f90e..2d08225e 100644 --- a/src/Controller/Kobo/AbstractKoboController.php +++ b/src/Controller/Kobo/AbstractKoboController.php @@ -4,6 +4,6 @@ use App\Controller\AbstractController; -class AbstractKoboController extends AbstractController +abstract class AbstractKoboController extends AbstractController { } diff --git a/src/Controller/Kobo/Api/ApiEndpointController.php b/src/Controller/Kobo/Api/ApiEndpointController.php new file mode 100644 index 00000000..655e8f12 --- /dev/null +++ b/src/Controller/Kobo/Api/ApiEndpointController.php @@ -0,0 +1,23 @@ +Hello Kobo'); + } +} diff --git a/src/Controller/Kobo/KoboImageController.php b/src/Controller/Kobo/Api/ImageController.php similarity index 93% rename from src/Controller/Kobo/KoboImageController.php rename to src/Controller/Kobo/Api/ImageController.php index 89171af9..9c5077e6 100644 --- a/src/Controller/Kobo/KoboImageController.php +++ b/src/Controller/Kobo/Api/ImageController.php @@ -1,7 +1,8 @@ getContent(); @@ -50,7 +51,7 @@ public function analyticsTests(Request $request): Response * @throws \JsonException * @throws GuzzleException */ - #[Route('/v1/analytics/event', methods: ['POST'])] + #[Route('/event', methods: ['POST'])] public function analyticsEvent(Request $request, KoboDevice $kobo): Response { // Save the device_id and model diff --git a/src/Controller/Kobo/KoboDownloadController.php b/src/Controller/Kobo/Api/V1/DownloadController.php similarity index 69% rename from src/Controller/Kobo/KoboDownloadController.php rename to src/Controller/Kobo/Api/V1/DownloadController.php index e47460c4..63d4c482 100644 --- a/src/Controller/Kobo/KoboDownloadController.php +++ b/src/Controller/Kobo/Api/V1/DownloadController.php @@ -1,29 +1,26 @@ '\d+', 'extension' => '[A-Za-z0-9]+'], methods: ['GET'])] + #[Route('/{id}.{extension}', name: 'download', requirements: ['bookId' => '\d+', 'extension' => '[A-Za-z0-9]+'], methods: ['GET'])] public function download(KoboDevice $kobo, Book $book, string $extension): Response { $this->assertCanDownload($kobo, $book); diff --git a/src/Controller/Kobo/Api/V1/GenericController.php b/src/Controller/Kobo/Api/V1/GenericController.php new file mode 100644 index 00000000..ad8067f9 --- /dev/null +++ b/src/Controller/Kobo/Api/V1/GenericController.php @@ -0,0 +1,70 @@ +koboStoreProxy->isEnabled()) { + return $this->koboStoreProxy->proxyOrRedirect($request); + } + + return new JsonResponse(['Benefits' => new \stdClass()]); + } + + /** + * @throws GuzzleException + */ + #[Route('/affiliate', methods: ['GET', 'POST'])] // ?PlatformID=00000000-0000-0000-0000-000000000384&SerialNumber=xxxxxxx + #[Route('/assets', methods: ['GET'])] + #[Route('/deals', methods: ['GET', 'POST'])] + #[Route('/products', methods: ['GET', 'POST'])] + #[Route('/products/books/external/{uuid}', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/products/books/series/{uuid}', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/products/books/{uuid}', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/products/books/{uuid}/', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/products/books/{uuid}/access', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/products/dailydeal', methods: ['GET', 'POST'])] + #[Route('/products/deals', methods: ['GET', 'POST'])] + #[Route('/products/featured/', methods: ['GET', 'POST'])] + #[Route('/products/featured/{uuid}', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/products/{uuid}/prices', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/products/{uuid}/recommendations', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/user/recommendations/feedback', methods: ['GET', 'POST'])] + #[Route('/products/{uuid}/reviews', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/user/profile')] + #[Route('/configuration')] + #[Route('/auth/device')] + #[Route('/auth/refresh')] + #[Route('/library/borrow')] + #[Route('/auth/exchange')] + #[Route('/library/{uuid}', methods: ['DELETE'])] + #[Route('/user/recommendations', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/user/wishlist')] // ?PageSize=100&PageIndex=0 + public function proxy(Request $request, LoggerInterface $koboLogger): Response + { + $koboLogger->info('Kobo API Proxy request on '.$request->getPathInfo(), ['request' => $request->getContent(), 'headers' => $request->headers->all()]); + + return $this->koboStoreProxy->proxyOrRedirect($request); + } +} diff --git a/src/Controller/Kobo/KoboInitializationController.php b/src/Controller/Kobo/Api/V1/InitializationController.php similarity index 74% rename from src/Controller/Kobo/KoboInitializationController.php rename to src/Controller/Kobo/Api/V1/InitializationController.php index 2b5c6e65..4f22e4ea 100644 --- a/src/Controller/Kobo/KoboInitializationController.php +++ b/src/Controller/Kobo/Api/V1/InitializationController.php @@ -1,7 +1,8 @@ logger->info('Initialization request'); + $this->koboSynclogger->info('Initialization request'); // Load the JSON data from the store // A hardcoded value is returned as fallback (see KoboProxyConfiguration::getNativeInitializationJson) $jsonData = $this->getJsonData($request); @@ -41,19 +41,26 @@ public function initialization(Request $request, KoboDevice $kobo): Response } // Override the Image Endpoint with the one from this server - $base = $this->generateUrl('koboapi_endpoint', [ + $base = $this->generateUrl('kobo_api_endpoint', [ 'accessKey' => $kobo->getAccessKey(), ], UrlGeneratorInterface::ABSOLUTE_URL); $base = rtrim($base, '/'); // Image host: https:// - $jsonData['Resources']['image_host'] = rtrim($this->generateUrl('app_dashboard', [], UrlGenerator::ABSOLUTE_URL), '/'); + $jsonData['Resources']['image_host'] = rtrim($this->generateUrl('app_dashboard', [], UrlGeneratorInterface::ABSOLUTE_URL), '/'); $jsonData['Resources']['image_url_template'] = $base.'/image/{ImageId}/{width}/{height}/{Quality}/isGreyscale/image.jpg'; $jsonData['Resources']['image_url_quality_template'] = $base.'/{ImageId}/{width}/{height}/false/image.jpg'; - // Reading services - $jsonData['Resources']['reading_services_host'] = rtrim($this->generateUrl('app_dashboard', [], UrlGenerator::ABSOLUTE_URL), '/'); + // Original value is "https://readingservices.kobo.com" event if the name is "host", it's an url. + $jsonData['Resources']['reading_services_host'] = rtrim($this->generateUrl('app_dashboard', [], UrlGeneratorInterface::ABSOLUTE_URL), '/'); + + foreach ($jsonData['Resources'] as &$url) { + if (!is_string($url)) { + continue; + } + $url = str_replace('https://storeapi.kobo.com', $base, $url); + } $response = new JsonResponse($jsonData); $response->headers->set('kobo-api-token', 'e30='); @@ -70,7 +77,7 @@ private function getJsonData(Request $request): array // Proxy is disabled, return the generic data if ($this->koboProxyConfiguration->useProxy() === false) { - $this->logger->info('Proxy is disabled, returning generic data'); + $this->koboSynclogger->info('Proxy is disabled, returning generic data'); return $genericData; } @@ -87,7 +94,7 @@ private function getJsonData(Request $request): array return (array) json_decode((string) $jsonResponse->getContent(), true, 512, JSON_THROW_ON_ERROR); } catch (GuzzleException|\RuntimeException|\JsonException $exception) { - $this->logger->warning('Unable to fetch initialization data', ['exception' => $exception]); + $this->koboSynclogger->warning('Unable to fetch initialization data', ['exception' => $exception]); return $genericData; } diff --git a/src/Controller/Kobo/Api/V1/LibraryController.php b/src/Controller/Kobo/Api/V1/LibraryController.php new file mode 100644 index 00000000..d73874b4 --- /dev/null +++ b/src/Controller/Kobo/Api/V1/LibraryController.php @@ -0,0 +1,222 @@ + '^[a-zA-Z0-9\-]+$'], methods: ['PUT'])] + public function putState(KoboDevice $kobo, string $uuid, Request $request): Response|JsonResponse + { + $book = $this->bookRepository->findByUuidAndKoboDevice($uuid, $kobo); + + // Handle book not found + if (!$book instanceof Book) { + if ($this->koboStoreProxy->isEnabled()) { + return $this->koboStoreProxy->proxy($request); + } + + return new JsonResponse(['error' => 'Book not found'], Response::HTTP_NOT_FOUND); + } + + // Deserialize request + /** @var ReadingStates $entity */ + $entity = $this->serializer->deserialize($request->getContent(), ReadingStates::class, 'json'); + + if (count($entity->readingStates) === 0) { + return new JsonResponse(['error' => 'No reading state provided'], Response::HTTP_BAD_REQUEST); + } + $state = $entity->readingStates[0]; + switch ($state->statusInfo?->status) { + case ReadingStateStatusInfo::STATUS_FINISHED: + $this->bookProgressionService->setProgression($book, $kobo->getUser(), 1.0); + break; + case ReadingStateStatusInfo::STATUS_READY_TO_READ: + $this->bookProgressionService->setProgression($book, $kobo->getUser(), null); + break; + case ReadingStateStatusInfo::STATUS_READING: + $progress = $state->currentBookmark?->progressPercent; + $progress = $progress !== null ? $progress / 100 : null; + $this->bookProgressionService->setProgression($book, $kobo->getUser(), $progress); + break; + case null: + break; + } + + $this->handleBookmark($kobo, $book, $state->currentBookmark); + + $this->em->flush(); + + return new StateResponse($book); + } + + /** + * @throws GuzzleException + */ + #[Route('/{uuid}/state', name: 'api_endpoint_v1_getstate', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET'])] + public function getState(KoboDevice $kobo, string $uuid, Request $request, SyncToken $syncToken): Response|JsonResponse + { + // Get State returns an empty response + $response = new JsonResponse([]); + $response->headers->set('x-kobo-api-token', 'e30='); + + $book = $this->bookRepository->findByUuidAndKoboDevice($uuid, $kobo); + + // Unknown book + if (!$book instanceof Book) { + if ($this->koboStoreProxy->isEnabled()) { + return $this->koboStoreProxy->proxyOrRedirect($request); + } + $response->setData(['error' => 'Book not found']); + + return $response->setStatusCode(Response::HTTP_NOT_IMPLEMENTED); + } + + $rsResponse = $this->readingStateResponseFactory->create($syncToken, $kobo, $book); + + $response->setContent($rsResponse); + + return $response; + } + + private function handleBookmark(KoboDevice $kobo, Book $book, ?Bookmark $currentBookmark): void + { + if (!$currentBookmark instanceof Bookmark) { + $kobo->getUser()->removeBookmarkForBook($book); + + return; + } + + $bookmark = $kobo->getUser()->getBookmarkForBook($book) ?? new BookmarkUser($book, $kobo->getUser()); + $this->em->persist($bookmark); + + $bookmark->setPercent($currentBookmark->progressPercent === null ? null : $currentBookmark->progressPercent / 100); + $bookmark->setLocationType($currentBookmark->location?->type); + $bookmark->setLocationSource($currentBookmark->location?->source); + $bookmark->setLocationValue($currentBookmark->location?->value); + $bookmark->setSourcePercent($currentBookmark->contentSourceProgressPercent === null ? null : $currentBookmark->contentSourceProgressPercent / 100); + } + + /** + * Sync library. + * + * An HTTP Header is passing the SyncToken option, and we fill also the filter from the get parameters into it. + * See KoboSyncTokenExtractor and Kobo + * Both + * Kobo will call this url multiple times if there are more book to sync (x-kobo-sync: continue) + * @param KoboDevice $kobo The kobo entity is retrieved via the accessKey in the url + * @param SyncToken $syncToken It's provided from HTTP Headers + Get parameters, see SyncTokenParamConverter and KoboSyncTokenExtractor + **/ + #[Route('/sync', name: 'api_endpoint_v1_library_sync')] + public function apiEndpoint(KoboDevice $kobo, SyncToken $syncToken, Request $request): Response + { + $forced = $kobo->isForceSync() || $request->query->has('force'); + $count = $this->koboSyncedBookRepository->countByKoboDevice($kobo); + if ($forced || $count === 0) { + if ($forced) { + $this->koboSyncLogger->debug('Force sync for Kobo {id}', ['id' => $kobo->getId()]); + $this->koboSyncedBookRepository->deleteAllSyncedBooks($kobo); + $kobo->setForceSync(false); + $this->koboDeviceRepository->save($kobo); + $syncToken->currentDate = new \DateTime('now'); + } + $this->koboSyncLogger->debug('First sync for Kobo {id}', ['id' => $kobo->getId()]); + $syncToken->lastCreated = null; + $syncToken->lastModified = null; + $syncToken->tagLastModified = null; + $syncToken->archiveLastModified = null; + } + + // We fetch a subset of book to sync, based on the SyncToken. + $books = $this->bookRepository->getChangedBooks($kobo, $syncToken, 0, self::MAX_BOOKS_PER_SYNC); + $count = $this->bookRepository->getChangedBooksCount($kobo, $syncToken); + $this->koboSyncLogger->debug("Sync for Kobo {id}: {$count} books to sync", ['id' => $kobo->getId(), 'count' => $count, 'token' => $syncToken]); + + $response = $this->syncResponseFactory->create($syncToken, $kobo) + ->addBooks($books) + ->addShelves($this->shelfRepository->getShelvesToSync($kobo, $syncToken)); + + // Fetch the books upstream and merge the answer + $shouldContinue = $this->upstreamSyncMerger->merge($kobo, $response, $request); + + // TODO Pagination based on the sync token and lastSyncDate + $httpResponse = $response->toJsonResponse(); + $httpResponse->headers->set('x-kobo-sync-todo', $shouldContinue || count($books) < $count ? 'continue' : 'done'); + + // Once the response is generated, we update the list of synced books + // If you do this before, the logic will be broken + if (false === $forced) { + $this->koboSyncLogger->debug('Set synced date for {count} downloaded books', ['count' => count($books)]); + + $this->koboSyncedBookRepository->updateSyncedBooks($kobo, $books, $syncToken); + } + + return $httpResponse; + } + + #[Route('/{uuid}/metadata', name: 'api_endpoint_v1_library_metadata')] + public function metadataEndpoint(KoboDevice $kobo, ?Book $book, Request $request): Response + { + if (!$book instanceof Book) { + if ($this->koboStoreProxy->isEnabled()) { + return $this->koboStoreProxy->proxy($request); + } + + return new JsonResponse(['error' => 'Book not found'], Response::HTTP_NOT_FOUND); + } + + return $this->syncResponseFactory->createMetadata($kobo, $book); + } +} diff --git a/src/Controller/Kobo/KoboNextReadController.php b/src/Controller/Kobo/Api/V1/ProductsController.php similarity index 64% rename from src/Controller/Kobo/KoboNextReadController.php rename to src/Controller/Kobo/Api/V1/ProductsController.php index f43aa9f5..8215ddc4 100644 --- a/src/Controller/Kobo/KoboNextReadController.php +++ b/src/Controller/Kobo/Api/V1/ProductsController.php @@ -1,21 +1,22 @@ '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] + #[Route('/{uuid}/nextread', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] public function nextRead(Request $request): Response { if ($this->koboStoreProxy->isEnabled()) { diff --git a/src/Controller/Kobo/KoboTagController.php b/src/Controller/Kobo/Api/V1/TagController.php similarity index 84% rename from src/Controller/Kobo/KoboTagController.php rename to src/Controller/Kobo/Api/V1/TagController.php index 7081f7f4..9c07eb7c 100644 --- a/src/Controller/Kobo/KoboTagController.php +++ b/src/Controller/Kobo/Api/V1/TagController.php @@ -1,7 +1,8 @@ shelfRepository->findByKoboAndUuid($kobo, $shelfId); @@ -44,7 +45,7 @@ public function delete(Request $request, KoboDevice $kobo, string $shelfId): Res // Avoid the Kobo to send the request over and over again by marking it successful if ($response->getStatusCode() === Response::HTTP_NOT_FOUND) { - $this->logger->debug('Shelf not found locally and via the proxy, marking deletion as successful anyway', ['shelfId' => $shelfId]); + $this->koboSyncLogger->debug('Shelf not found locally and via the proxy, marking deletion as successful anyway', ['shelfId' => $shelfId]); return new JsonResponse($shelfId, Response::HTTP_CREATED); } @@ -54,7 +55,7 @@ public function delete(Request $request, KoboDevice $kobo, string $shelfId): Res /** @var TagDeleteRequest $deleteRequest */ $deleteRequest = $this->serializer->deserialize($request->getContent(false), TagDeleteRequest::class, 'json'); - $this->logger->debug('Tag delete request', ['request' => $deleteRequest]); + $this->koboSyncLogger->debug('Tag delete request', ['request' => $deleteRequest]); try { if (!$shelf instanceof Shelf) { @@ -74,8 +75,8 @@ public function delete(Request $request, KoboDevice $kobo, string $shelfId): Res return new JsonResponse($shelfId, Response::HTTP_CREATED); } - #[Route('/v1/library/tags')] - #[Route('/v1/library/tags/{tagId}')] + #[Route('/')] + #[Route('/{tagId}')] public function tags(Request $request, KoboDevice $kobo, ?string $tagId = null): Response { try { @@ -91,14 +92,14 @@ public function tags(Request $request, KoboDevice $kobo, ?string $tagId = null): if ($request->isMethod('DELETE')) { if ($shelf instanceof Shelf) { - $this->logger->debug('Removing kobo from shelf', ['shelf' => $shelf, 'kobo' => $kobo]); + $this->koboSyncLogger->debug('Removing kobo from shelf', ['shelf' => $shelf, 'kobo' => $kobo]); $shelf->removeKoboDevice($kobo); $this->shelfRepository->flush(); return new JsonResponse(['deleted'], Response::HTTP_OK); } if ($this->koboStoreProxy->isEnabled()) { - $this->logger->debug('Proxying request to delete tag {id}', ['id' => $tagId]); + $this->koboSyncLogger->debug('Proxying request to delete tag {id}', ['id' => $tagId]); $proxyResponse = $this->koboStoreProxy->proxy($request); if ($proxyResponse->getStatusCode() === Response::HTTP_NOT_FOUND) { diff --git a/src/Controller/Kobo/Api/V3/ContentController.php b/src/Controller/Kobo/Api/V3/ContentController.php new file mode 100644 index 00000000..f8ce395f --- /dev/null +++ b/src/Controller/Kobo/Api/V3/ContentController.php @@ -0,0 +1,45 @@ +koboStoreProxy->isEnabled()) { + $response = $this->koboStoreProxy->proxy($request); + if ($response->isOk()) { + return $response; + } + } + + return new JsonResponse([]); + } +} diff --git a/src/Controller/Kobo/KoboAnnotationsController.php b/src/Controller/Kobo/KoboAnnotationsController.php deleted file mode 100644 index 5e57190d..00000000 --- a/src/Controller/Kobo/KoboAnnotationsController.php +++ /dev/null @@ -1,20 +0,0 @@ - '^[a-zA-Z0-9\-]+$'])] - public function state(string $uuid): Response|JsonResponse - { - return new JsonResponse([]); - } -} diff --git a/src/Controller/Kobo/KoboBenefitsController.php b/src/Controller/Kobo/KoboBenefitsController.php deleted file mode 100644 index 20160100..00000000 --- a/src/Controller/Kobo/KoboBenefitsController.php +++ /dev/null @@ -1,36 +0,0 @@ -koboStoreProxy->isEnabled()) { - return $this->koboStoreProxy->proxyOrRedirect($request); - } - - return new JsonResponse(['Benefits' => new \stdClass()]); - } -} diff --git a/src/Controller/Kobo/KoboController.php b/src/Controller/Kobo/KoboController.php deleted file mode 100644 index bb10b192..00000000 --- a/src/Controller/Kobo/KoboController.php +++ /dev/null @@ -1,60 +0,0 @@ -Hello Kobo'); - } - - /** - * @throws GuzzleException - */ - #[Route('/v1/affiliate', methods: ['GET', 'POST'])] // ?PlatformID=00000000-0000-0000-0000-000000000384&SerialNumber=xxxxxxx - #[Route('/v1/assets', methods: ['GET'])] - #[Route('/v1/deals', methods: ['GET', 'POST'])] - #[Route('/v1/products', methods: ['GET', 'POST'])] - #[Route('/v1/products/books/external/{uuid}', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/products/books/series/{uuid}', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/products/books/{uuid}', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/products/books/{uuid}/', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/products/books/{uuid}/access', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/products/dailydeal', methods: ['GET', 'POST'])] - #[Route('/v1/products/deals', methods: ['GET', 'POST'])] - #[Route('/v1/products/featured/', methods: ['GET', 'POST'])] - #[Route('/v1/products/featured/{uuid}', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/products/{uuid}/prices', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/products/{uuid}/recommendations', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/products/{uuid}/reviews', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/user/profile')] - #[Route('/v1/configuration')] - #[Route('/v1/auth/device')] - #[Route('/v1/auth/refresh')] - #[Route('/v1/library/borrow')] - #[Route('/v1/auth/exchange')] - #[Route('/v1/library/{uuid}', methods: ['DELETE'])] - #[Route('/v1/user/recommendations', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET', 'POST'])] - #[Route('/v1/user/wishlist')] // ?PageSize=100&PageIndex=0 - public function proxy(Request $request, LoggerInterface $koboLogger): Response - { - $koboLogger->info('Kobo API Proxy request on '.$request->getPathInfo(), ['request' => $request->getContent(), 'headers' => $request->headers->all()]); - - return $this->koboStoreProxy->proxyOrRedirect($request); - } -} diff --git a/src/Controller/Kobo/KoboDeviceController.php b/src/Controller/Kobo/KoboDeviceController.php index 15d51e15..a5dd7d8b 100644 --- a/src/Controller/Kobo/KoboDeviceController.php +++ b/src/Controller/Kobo/KoboDeviceController.php @@ -9,7 +9,7 @@ use Devdot\Monolog\Parser; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -18,6 +18,14 @@ #[Route('/user/kobo')] class KoboDeviceController extends AbstractController { + public function __construct( + #[Autowire(param: 'kernel.logs_dir')] + protected string $kernelLogsDir, + #[Autowire(param: 'kernel.environment')] + protected string $kernelEnvironment, + ) { + } + #[Route('/', name: 'app_kobodevice_user_index', methods: ['GET'])] public function index(KoboDeviceRepository $koboDeviceRepository): Response { @@ -30,35 +38,6 @@ public function index(KoboDeviceRepository $koboDeviceRepository): Response ]); } - #[Route('/logs', name: 'app_kobodevice_user_logs', methods: ['GET'])] - public function logs(ParameterBagInterface $parameterBag): Response - { - if (!$this->getUser() instanceof UserInterface) { - throw $this->createAccessDeniedException(); - } - - $records = []; - - try { - $logDir = $parameterBag->get('kernel.logs_dir'); - $env = $parameterBag->get('kernel.environment'); - - if (!is_string($logDir) || !is_string($env)) { - throw new \RuntimeException('Invalid log directory or environment'); - } - - $parser = new Parser($logDir.'/kobo.'.$env.'-'.date('Y-m-d').'.log'); - - $records = $parser->get(); - } catch (\Exception $e) { - $this->addFlash('warning', $e->getMessage()); - } - - return $this->render('kobodevice_user/logs.html.twig', [ - 'records' => $records, - ]); - } - #[Route('/new', name: 'app_kobodevice_user_new', methods: ['GET', 'POST'])] public function new(Request $request, EntityManagerInterface $entityManager): Response { @@ -125,4 +104,26 @@ public function delete(Request $request, KoboDevice $koboDevice, EntityManagerIn return $this->redirectToRoute('app_kobodevice_user_index', [], Response::HTTP_SEE_OTHER); } + + #[Route('/logs', name: 'app_kobodevice_user_logs', methods: ['GET'])] + public function logs(): Response + { + if (!$this->getUser() instanceof UserInterface) { + throw $this->createAccessDeniedException(); + } + + $records = []; + + try { + $parser = new Parser($this->kernelLogsDir.'/kobo.'.$this->kernelEnvironment.'-'.date('Y-m-d').'.log'); + + $records = $parser->get(); + } catch (\Exception $e) { + $this->addFlash('warning', $e->getMessage()); + } + + return $this->render('kobodevice_user/logs.html.twig', [ + 'records' => $records, + ]); + } } diff --git a/src/Controller/Kobo/KoboStateController.php b/src/Controller/Kobo/KoboStateController.php deleted file mode 100644 index 0d32e6d0..00000000 --- a/src/Controller/Kobo/KoboStateController.php +++ /dev/null @@ -1,135 +0,0 @@ - '^[a-zA-Z0-9\-]+$'], methods: ['PUT'])] - public function state(KoboDevice $kobo, string $uuid, Request $request): Response|JsonResponse - { - $book = $this->bookRepository->findByUuidAndKoboDevice($uuid, $kobo); - - // Handle book not found - if (!$book instanceof Book) { - if ($this->koboStoreProxy->isEnabled()) { - return $this->koboStoreProxy->proxy($request); - } - - return new JsonResponse(['error' => 'Book not found'], Response::HTTP_NOT_FOUND); - } - - // Deserialize request - /** @var ReadingStates $entity */ - $entity = $this->serializer->deserialize($request->getContent(), ReadingStates::class, 'json'); - - if (count($entity->readingStates) === 0) { - return new JsonResponse(['error' => 'No reading state provided'], Response::HTTP_BAD_REQUEST); - } - $state = $entity->readingStates[0]; - switch ($state->statusInfo?->status) { - case ReadingStateStatusInfo::STATUS_FINISHED: - $this->bookProgressionService->setProgression($book, $kobo->getUser(), 1.0); - break; - case ReadingStateStatusInfo::STATUS_READY_TO_READ: - $this->bookProgressionService->setProgression($book, $kobo->getUser(), null); - break; - case ReadingStateStatusInfo::STATUS_READING: - $progress = $state->currentBookmark?->progressPercent; - $progress = $progress !== null ? $progress / 100 : null; - $this->bookProgressionService->setProgression($book, $kobo->getUser(), $progress); - break; - case null: - break; - } - - $this->handleBookmark($kobo, $book, $state->currentBookmark); - - $this->em->flush(); - - return new StateResponse($book); - } - - /** - * @throws GuzzleException - */ - #[Route('/v1/library/{uuid}/state', name: 'api_endpoint_v1_getstate', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET'])] - public function getState(KoboDevice $kobo, string $uuid, Request $request, SyncToken $syncToken, LoggerInterface $logger): Response|JsonResponse - { - // Get State returns an empty response - $response = new JsonResponse([]); - $response->headers->set('x-kobo-api-token', 'e30='); - - $book = $this->bookRepository->findByUuidAndKoboDevice($uuid, $kobo); - - // Unknown book - if (!$book instanceof Book) { - if ($this->koboStoreProxy->isEnabled()) { - return $this->koboStoreProxy->proxyOrRedirect($request); - } - $response->setData(['error' => 'Book not found']); - - return $response->setStatusCode(Response::HTTP_NOT_IMPLEMENTED); - } - - $rsResponse = $this->readingStateResponseFactory->create($syncToken, $kobo, $book); - - $response->setContent($rsResponse); - - $logger->info('Returned Kobo State for book', ['response' => $rsResponse]); - - return $response; - } - - private function handleBookmark(KoboDevice $kobo, Book $book, ?Bookmark $currentBookmark): void - { - if (!$currentBookmark instanceof Bookmark) { - $kobo->getUser()->removeBookmarkForBook($book); - - return; - } - - $bookmark = $kobo->getUser()->getBookmarkForBook($book) ?? new BookmarkUser($book, $kobo->getUser()); - $this->em->persist($bookmark); - - $bookmark->setPercent($currentBookmark->progressPercent === null ? null : $currentBookmark->progressPercent / 100); - $bookmark->setLocationType($currentBookmark->location?->type); - $bookmark->setLocationSource($currentBookmark->location?->source); - $bookmark->setLocationValue($currentBookmark->location?->value); - $bookmark->setSourcePercent($currentBookmark->contentSourceProgressPercent === null ? null : $currentBookmark->contentSourceProgressPercent / 100); - } -} diff --git a/src/Controller/Kobo/KoboSyncController.php b/src/Controller/Kobo/KoboSyncController.php deleted file mode 100644 index 2c8f7083..00000000 --- a/src/Controller/Kobo/KoboSyncController.php +++ /dev/null @@ -1,104 +0,0 @@ -isForceSync() || $request->query->has('force'); - $count = $this->koboSyncedBookRepository->countByKoboDevice($kobo); - if ($forced || $count === 0) { - if ($forced) { - $this->logger->debug('Force sync for Kobo {id}', ['id' => $kobo->getId()]); - $this->koboSyncedBookRepository->deleteAllSyncedBooks($kobo); - $kobo->setForceSync(false); - $syncToken->currentDate = new \DateTime('now'); - } - $this->logger->debug('First sync for Kobo {id}', ['id' => $kobo->getId()]); - $syncToken->lastCreated = null; - $syncToken->lastModified = null; - $syncToken->tagLastModified = null; - $syncToken->archiveLastModified = null; - } - - // We fetch a subset of book to sync, based on the SyncToken. - $books = $this->bookRepository->getChangedBooks($kobo, $syncToken, 0, self::MAX_BOOKS_PER_SYNC); - $count = $this->bookRepository->getChangedBooksCount($kobo, $syncToken); - $this->logger->debug("Sync for Kobo {id}: {$count} books to sync", ['id' => $kobo->getId(), 'count' => $count, 'token' => $syncToken]); - - $response = $this->syncResponseFactory->create($syncToken, $kobo) - ->addBooks($books) - ->addShelves($this->shelfRepository->getShelvesToSync($kobo, $syncToken)); - - // TODO Pagination based on the sync token and lastSyncDate - $httpResponse = $response->toJsonResponse(); - $httpResponse->headers->set('x-kobo-sync-todo', count($books) < $count ? 'continue' : 'done'); - - // Once the response is generated, we update the list of synced books - // If you do this before, the logic will be broken - if (false === $forced) { - $this->logger->debug('Set synced date for {count} downloaded books', ['count' => count($books)]); - - $this->koboSyncedBookRepository->updateSyncedBooks($kobo, $books, $syncToken); - } - - return $httpResponse; - } - - #[Route('/v1/library/{uuid}/metadata', name: 'api_endpoint_v1_library_metadata')] - public function metadataEndpoint(KoboDevice $kobo, ?Book $book, Request $request): Response - { - if (!$book instanceof Book) { - if ($this->koboStoreProxy->isEnabled()) { - return $this->koboStoreProxy->proxy($request); - } - - return new JsonResponse(['error' => 'Book not found'], Response::HTTP_NOT_FOUND); - } - - return $this->syncResponseFactory->createMetadata($kobo, $book); - } -} diff --git a/src/Controller/Kobo/KoboUserProfile.php b/src/Controller/Kobo/KoboUserProfile.php deleted file mode 100644 index 5c79f575..00000000 --- a/src/Controller/Kobo/KoboUserProfile.php +++ /dev/null @@ -1,27 +0,0 @@ -koboStoreProxy->proxy($request); - } -} diff --git a/src/Controller/Kobo/ReadServiceCheckForChangesController.php b/src/Controller/Kobo/ReadServiceCheckForChangesController.php deleted file mode 100644 index d4b1c4f4..00000000 --- a/src/Controller/Kobo/ReadServiceCheckForChangesController.php +++ /dev/null @@ -1,20 +0,0 @@ - false])] private bool $forceSync = false; + #[ORM\Column(type: 'boolean', options: ['default' => false])] + private bool $upstreamSync = false; + public function __construct() { $this->shelves = new ArrayCollection(); @@ -195,4 +198,14 @@ public function setModel(?string $model): self return $this; } + + public function isUpstreamSync(): bool + { + return $this->upstreamSync; + } + + public function setUpstreamSync(bool $upstreamSync): void + { + $this->upstreamSync = $upstreamSync; + } } diff --git a/src/EventSubscriber/KoboRequestSubscriber.php b/src/EventSubscriber/KoboLogRequestSubscriber.php similarity index 77% rename from src/EventSubscriber/KoboRequestSubscriber.php rename to src/EventSubscriber/KoboLogRequestSubscriber.php index 7037317a..6fdf8b3e 100644 --- a/src/EventSubscriber/KoboRequestSubscriber.php +++ b/src/EventSubscriber/KoboLogRequestSubscriber.php @@ -9,9 +9,9 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; -class KoboRequestSubscriber implements EventSubscriberInterface +class KoboLogRequestSubscriber implements EventSubscriberInterface { - public function __construct(protected LoggerInterface $koboLogger) + public function __construct(protected LoggerInterface $koboHttpLogger) { } @@ -33,7 +33,7 @@ public function onKernelResponse(ResponseEvent $event): void $content = $event->getResponse()->getContent(); } - $this->koboLogger->info('Response from '.$event->getRequest()->getPathInfo(), ['response' => $content, 'headers' => $event->getResponse()->headers->all()]); + $this->koboHttpLogger->info('Response given '.$event->getRequest()->getPathInfo(), ['response' => $content, 'headers' => $event->getResponse()->headers->all()]); } public function onKernelController(ControllerEvent $event): void @@ -57,7 +57,7 @@ public function onKernelController(ControllerEvent $event): void $content = $event->getRequest()->getContent(); } - $this->koboLogger->info($event->getRequest()->getMethod().' on '.$event->getRequest()->getPathInfo(), ['request' => $content, 'headers' => $event->getRequest()->headers->all()]); + $this->koboHttpLogger->info($event->getRequest()->getMethod().' on '.$event->getRequest()->getPathInfo(), ['request' => $content, 'headers' => $event->getRequest()->headers->all()]); } public static function getSubscribedEvents(): array diff --git a/src/Form/KoboType.php b/src/Form/KoboType.php index fc238419..f0c10b70 100644 --- a/src/Form/KoboType.php +++ b/src/Form/KoboType.php @@ -4,6 +4,7 @@ use App\Entity\KoboDevice; use App\Entity\Shelf; +use App\Kobo\Proxy\KoboProxyConfiguration; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -14,8 +15,10 @@ class KoboType extends AbstractType { - public function __construct(protected Security $security) - { + public function __construct( + protected Security $security, + protected KoboProxyConfiguration $koboProxyConfiguration, + ) { } public function buildForm(FormBuilderInterface $builder, array $options): void @@ -36,6 +39,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('forceSync', null, [ 'label' => 'Force Sync', 'required' => false, + ]) + ->add('upstreamSync', null, [ + 'label' => 'Sync books with the official store too', + 'required' => false, + 'disabled' => !$this->koboProxyConfiguration->useProxy(), ]); $builder->add('shelves', EntityType::class, [ 'label' => 'Sync with Shelves', diff --git a/src/Kobo/DownloadHelper.php b/src/Kobo/DownloadHelper.php index 5bbefc67..aa7143b6 100644 --- a/src/Kobo/DownloadHelper.php +++ b/src/Kobo/DownloadHelper.php @@ -42,7 +42,7 @@ public function getSize(Book $book): int private function getUrlForKoboDevice(Book $book, KoboDevice $kobo, string $extension): string { - return $this->urlGenerator->generate('app_kobodownload', [ + return $this->urlGenerator->generate('kobo_download', [ 'id' => $book->getId(), 'accessKey' => $kobo->getAccessKey(), 'extension' => strtolower($extension), diff --git a/src/Kobo/Kepubify/KepubifyMessageHandler.php b/src/Kobo/Kepubify/KepubifyMessageHandler.php index 73980a2d..2d701a95 100644 --- a/src/Kobo/Kepubify/KepubifyMessageHandler.php +++ b/src/Kobo/Kepubify/KepubifyMessageHandler.php @@ -19,7 +19,7 @@ class KepubifyMessageHandler public function __construct( #[Autowire(param: 'kernel.cache_dir')] private readonly string $cacheDir, - private readonly LoggerInterface $logger, + private readonly LoggerInterface $koboKepubify, private readonly KepubifyEnabler $kepubifyEnabler, private readonly CacheItemPoolInterface $kepubifyCachePool, ) { @@ -35,7 +35,7 @@ public function __invoke(KepubifyMessage $message): void // Create a temporary file $temporaryFile = $this->getTemporaryFilename(); if ($temporaryFile === false) { - $this->logger->error('Error while creating temporary file'); + $this->koboKepubify->error('Error while creating temporary file'); return; } @@ -44,7 +44,7 @@ public function __invoke(KepubifyMessage $message): void try { $item = $this->kepubifyCachePool->getItem('kepubify_object_'.md5($message->source)); } catch (InvalidArgumentException $e) { - $this->logger->error('Error while caching kepubify: {error}', [ + $this->koboKepubify->error('Error while caching kepubify: {error}', [ 'error' => $e->getMessage(), 'exception' => $e, ]); @@ -79,7 +79,7 @@ public function __invoke(KepubifyMessage $message): void try { $this->kepubifyCachePool->deleteItem($item->getKey()); } catch (InvalidArgumentException $e) { - $this->logger->error('Error while deleting cached kepubify data: {error}', [ + $this->koboKepubify->error('Error while deleting cached kepubify data: {error}', [ 'error' => $e->getMessage(), 'exception' => $e, ]); @@ -90,7 +90,7 @@ public function __invoke(KepubifyMessage $message): void $result = file_put_contents($temporaryFile, $data->getContent()); if ($result === false) { - $this->logger->error('Error while restoring cached kepubify data'); + $this->koboKepubify->error('Error while restoring cached kepubify data'); $temporaryFile = null; } $message->destination = $temporaryFile; @@ -118,11 +118,11 @@ private function convert(KepubifyMessage $message, string $temporaryFile): ?stri { // Run the conversion $process = new Process([$this->kepubifyEnabler->getKepubifyBinary(), '--output', $temporaryFile, $message->source]); - $this->logger->debug('Run kepubify command: {command}', ['command' => $process->getCommandLine()]); + $this->koboKepubify->debug('Run kepubify command: {command}', ['command' => $process->getCommandLine()]); $process->run(); if (!$process->isSuccessful()) { - $this->logger->error('Error while running kepubify: {output}: {error}', [ + $this->koboKepubify->error('Error while running kepubify: {output}: {error}', [ 'output' => $process->getOutput(), 'error' => $process->getErrorOutput(), ]); diff --git a/src/Kobo/LogProcessor/KoboContextProcessor.php b/src/Kobo/LogProcessor/KoboContextProcessor.php new file mode 100644 index 00000000..2e29e3b2 --- /dev/null +++ b/src/Kobo/LogProcessor/KoboContextProcessor.php @@ -0,0 +1,47 @@ +requestStack->getCurrentRequest(); + + if (!$request instanceof Request) { + return $record; + } + + if (false === $request->attributes->has('isKoboRequest')) { + return $record; + } + + $kobo = $this->getKoboFromRequest($request); + $koboString = $kobo?->getId() ?? 'unknown'; + $record->extra['kobo'] = $koboString; + + return $record; + } + + private function getKoboFromRequest(Request $request): ?KoboDevice + { + $device = $request->attributes->get('koboDevice'); + if ($device instanceof KoboDevice) { + return $device; + } + + return $this->koboParamConverter->apply($request); + } +} diff --git a/src/Kobo/ParamConverter/KoboParamConverter.php b/src/Kobo/ParamConverter/KoboParamConverter.php index 7da71b59..706e4a10 100644 --- a/src/Kobo/ParamConverter/KoboParamConverter.php +++ b/src/Kobo/ParamConverter/KoboParamConverter.php @@ -24,6 +24,9 @@ public function supports(Request $request, ArgumentMetadata $argument): bool public function apply(Request $request): ?KoboDevice { $value = $this->getFieldValue($request); + if ($value === null) { + return null; + } return $this->bookRepository->findOneBy([$this->getFieldName() => $value]); } diff --git a/src/Kobo/Proxy/KoboHeaderFilterTrait.php b/src/Kobo/Proxy/KoboHeaderFilterTrait.php index 604845d1..883613dc 100644 --- a/src/Kobo/Proxy/KoboHeaderFilterTrait.php +++ b/src/Kobo/Proxy/KoboHeaderFilterTrait.php @@ -2,10 +2,10 @@ namespace App\Kobo\Proxy; +use GuzzleHttp\Psr7\Response; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Symfony\Component\HttpFoundation\Response; trait KoboHeaderFilterTrait { @@ -39,36 +39,32 @@ private function cleanup(ServerRequestInterface $request): ServerRequestInterfac ; } - private function cleanupGuzzle(\GuzzleHttp\Psr7\Response $response): MessageInterface + private function cleanupGuzzle(Response $response): MessageInterface { return $response ->withoutHeader('X-debug-token') ->withoutHeader('X-debug-link'); } - private function cleanupResponse(Response $response): void - { - $response->headers->remove('X-debug-token'); - $response->headers->remove('X-debug-link'); - // $response->headers->remove('x-powered-By'); - // $response->headers->remove('x-forwarded-for'); - // $response->headers->remove('x-forwarded-port'); - // $response->headers->remove('x-forwarded-proto'); - // $response->headers->remove('x-forwarded-host'); - // $response->headers->remove('x-scheme'); - // $response->headers->remove('x-php-ob-level');; - // $response->headers->remove('server');; - // $response->headers->remove('connection');; - // $response->headers->remove('content-encoding');; - // $response->headers->remove('content-length');; - // $response->headers->remove('"transfer-encoding');; - // $response->headers->remove('"transfer-encoding');; - } - private function cleanupPsrResponse(ResponseInterface $response): ResponseInterface { return $response ->withoutHeader('X-Debug-Token') - ->withoutHeader('X-Debug-Link'); + ->withoutHeader('X-Debug-Link') + ->withoutHeader('X-powered-By') + ->withoutHeader('X-Forwarded-For') + ->withoutHeader('X-Forwarded-Port') + ->withoutHeader('X-Forwarded-Proto') + ->withoutHeader('X-Forwarded-Host') + ->withoutHeader('X-Powered-By') + ->withoutHeader('X-Scheme') + ->withoutHeader('Server') + ->withoutHeader('Connection') + ->withoutHeader('Content-Encoding') + ->withoutHeader('Content-Length') + ->withoutHeader('Transfer-Encoding') + ->withoutHeader('Host') + ->withoutHeader('Server') + ; } } diff --git a/src/Kobo/Proxy/KoboProxyConfiguration.php b/src/Kobo/Proxy/KoboProxyConfiguration.php index 5c5c9aa2..8d79ae27 100644 --- a/src/Kobo/Proxy/KoboProxyConfiguration.php +++ b/src/Kobo/Proxy/KoboProxyConfiguration.php @@ -12,6 +12,7 @@ class KoboProxyConfiguration private string $imageApiUrl = ''; private string $storeApiUrl = ''; + private string $readingServiceUrl = ''; public function useProxy(): bool { @@ -57,6 +58,13 @@ public function isImageHostUrl(Request|RequestInterface $request): bool || str_ends_with($uri, '.jpeg'); } + public function isReadingServiceUrl(Request|RequestInterface $request): bool + { + $uri = $request instanceof Request ? $request->getRequestUri() : (string) $request->getUri(); + + return str_contains($uri, '/api/v3/content/'); + } + public function setImageApiUrl(string $imageApiUrl): KoboProxyConfiguration { $this->imageApiUrl = $imageApiUrl; @@ -74,7 +82,7 @@ public function setEnabled(bool $useProxy): KoboProxyConfiguration public function getNativeInitializationJson(): array { return [ - 'account_page' => 'https://secure.kobobooks.com/profile', + '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}', @@ -88,19 +96,23 @@ public function getNativeInitializationJson(): array 'audiobook_subscription_orange_deal_inclusion_url' => 'https://authorize.kobo.com/inclusion', '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'], + 'blackstone_header' => [ + 'key' => 'x-amz-request-payer', + 'value' => 'requester', + ], 'book' => 'https://storeapi.kobo.com/v1/products/books/{ProductId}', - 'book_detail_page' => 'https://store.kobobooks.com/{culture}/ebook/{slug}', - 'book_detail_page_rakuten' => 'https://books.rakuten.co.jp/rk/{crossrevisionid}', - 'book_landing_page' => 'https://store.kobobooks.com/ebooks', + '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://store.kobobooks.com/ebooks/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', @@ -113,12 +125,15 @@ public function getNativeInitializationJson(): array 'device_refresh' => 'https://storeapi.kobo.com/v1/auth/refresh', 'dictionary_host' => 'https://ereaderfiles.kobo.com', 'discovery_host' => 'https://discovery.kobobooks.com', + 'display_parental_controls_enabled' => 'False', + 'ereaderdevices' => 'https://storeapi.kobo.com/v2/products/EReaderDeviceFeeds', '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://store.kobobooks.com/', + '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', + 'featuredlist2' => 'https://storeapi.kobo.com/v2/products/list/featured', 'free_books_page' => [ 'EN' => 'https://www.kobo.com/{region}/{language}/p/free-ebooks', 'FR' => 'https://www.kobo.com/{region}/{language}/p/livres-gratuits', @@ -134,22 +149,25 @@ public function getNativeInitializationJson(): array 'giftcard_epd_redeem_url' => 'https://www.kobo.com/{storefront}/{language}/redeem-ereader', 'giftcard_redeem_url' => 'https://www.kobo.com/{storefront}/{language}/redeem', 'gpb_flow_enabled' => 'False', - 'help_page' => 'https://www.kobo.com/help', + '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' => 'False', - 'kobo_audiobooks_enabled' => 'False', + 'kobo_audiobooks_enabled' => 'True', 'kobo_audiobooks_orange_deal_enabled' => 'False', 'kobo_audiobooks_subscriptions_enabled' => 'False', 'kobo_display_price' => 'True', 'kobo_dropbox_link_account_enabled' => 'False', 'kobo_google_tax' => 'False', 'kobo_googledrive_link_account_enabled' => 'False', - 'kobo_nativeborrow_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' => 'False', + 'kobo_subscriptions_enabled' => 'True', 'kobo_superpoints_enabled' => 'False', 'kobo_wishlist_enabled' => 'True', 'library_book' => 'https://storeapi.kobo.com/v1/user/library/books/{LibraryItemId}', @@ -157,20 +175,16 @@ public function getNativeInitializationJson(): array '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_stack' => 'https://storeapi.kobo.com/v1/user/library/stacks/{LibraryItemId}', 'library_sync' => 'https://storeapi.kobo.com/v1/library/sync', - 'love_dashboard_page' => 'https://store.kobobooks.com/{culture}/kobosuperpoints', - 'love_points_redemption_page' => 'https://store.kobobooks.com/{culture}/KoboSuperPointsRedemption?productId={ProductId}', - 'magazine_landing_page' => 'https://store.kobobooks.com/emagazines', + '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', - 'overdrive_account' => 'https://auth.overdrive.com/account', - 'overdrive_library' => 'https://{libraryKey}.auth.overdrive.com/library', - 'overdrive_library_finder_host' => 'https://libraryfinder.api.overdrive.com', - 'overdrive_thunder_host' => 'https://thunder.api.overdrive.com', - 'password_retrieval_page' => 'https://www.kobobooks.com/passwordretrieval.html', + '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', @@ -181,38 +195,42 @@ public function getNativeInitializationJson(): array '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://store.kobobooks.com/', - 'purchase_buy' => 'https://www.kobo.com/checkout/createpurchase/', - 'purchase_buy_templated' => 'https://www.kobo.com/{culture}/checkout/createpurchase/{ProductId}', + '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://store.kobobooks.com', - 'registration_page' => 'https://authorize.kobo.com/signup?returnUrl=http://store.kobobooks.com/', + '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://authorize.kobo.com/signin?returnUrl=http://store.kobobooks.com/', + 'sign_in_page' => 'https://auth.kobobooks.com/ActivateOnWeb', 'social_authorization_host' => 'https://social.kobobooks.com:8443', 'social_host' => 'https://social.kobobooks.com', - 'stacks_host_productId' => 'https://store.kobobooks.com/collections/byproductid/', 'store_home' => 'www.kobo.com/{region}/{language}', - 'store_host' => 'store.kobobooks.com', - 'store_newreleases' => 'https://store.kobobooks.com/{culture}/List/new-releases/961XUjtsU0qxkFItWOutGA', - 'store_search' => 'https://store.kobobooks.com/{culture}/Search?Query={query}', - 'store_top50' => 'https://store.kobobooks.com/{culture}/ebooks/Top', + '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_plans_page' => 'https://www.kobo.com/{region}/{language}/plus/plans', + 'subs_purchase_buy_templated' => 'https://www.kobo.com/{region}/{language}/Checkoutoption/{ProductId}/{TierId}', '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', + 'topproducts' => 'https://storeapi.kobo.com/v2/products/list/topproducts', 'update_accessibility_to_preview' => 'https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview', - 'use_one_store' => 'False', + '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', @@ -221,7 +239,7 @@ public function getNativeInitializationJson(): array '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://store.kobobooks.com/{region}/{language}/account/wishlist', + 'wishlist_page' => 'https://www.kobo.com/{region}/{language}/account/wishlist', ]; } @@ -231,4 +249,20 @@ public function setUseProxyEverywhere(bool $useProxyEverywhere): self return $this; } + + public function getReadingServiceUrl(): string + { + if ($this->readingServiceUrl === '') { + throw new \InvalidArgumentException('Reading Service URL is not set'); + } + + return $this->readingServiceUrl; + } + + public function setReadingServiceUrl(string $readingServiceUrl): self + { + $this->readingServiceUrl = $readingServiceUrl; + + return $this; + } } diff --git a/src/Kobo/Proxy/KoboProxyLogger.php b/src/Kobo/Proxy/KoboProxyLogger.php index 02ac2272..25b8b402 100644 --- a/src/Kobo/Proxy/KoboProxyLogger.php +++ b/src/Kobo/Proxy/KoboProxyLogger.php @@ -11,8 +11,11 @@ class KoboProxyLogger { use KoboHeaderFilterTrait; - public function __construct(protected KoboProxyConfiguration $configuration, protected LoggerInterface $logger, protected string $accessToken) - { + public function __construct( + protected KoboProxyConfiguration $configuration, + protected LoggerInterface $koboProxyLogger, + protected string $accessToken, + ) { } /** @@ -54,14 +57,31 @@ protected function onFailure(RequestInterface $request): \Closure private function log(RequestInterface $request, ?ResponseInterface $response = null, ?\Throwable $error = null): void { - $this->logger->info(sprintf('Proxied: %s', (string) $request->getUri()), [ + try { + $requestContent = json_decode($request->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $requestContent = $request->getBody()->getContents(); + } + $responseContent = $response?->getBody()->getContents(); + if ($responseContent !== null) { + try { + $responseContent = json_decode($responseContent, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + } + } + + $this->koboProxyLogger->info(sprintf($request->getMethod().': %s', (string) $request->getUri()), [ 'method' => $request->getMethod(), 'status' => $response?->getStatusCode(), 'token_hash' => md5($this->accessToken), + 'request' => $requestContent, + 'response' => $responseContent, + 'request_headers' => $request->getHeaders(), + 'response_headers' => $response?->getHeaders(), ]); if ($error instanceof \Throwable) { - $this->logger->error('Proxy error: '.$error->getMessage(), [ + $this->koboProxyLogger->error('Proxy error: '.$error->getMessage(), [ 'exception' => $error, 'token_hash' => md5($this->accessToken), ]); diff --git a/src/Kobo/Proxy/KoboProxyLoggerFactory.php b/src/Kobo/Proxy/KoboProxyLoggerFactory.php index fe25281f..c2249226 100644 --- a/src/Kobo/Proxy/KoboProxyLoggerFactory.php +++ b/src/Kobo/Proxy/KoboProxyLoggerFactory.php @@ -8,13 +8,15 @@ class KoboProxyLoggerFactory { - public function __construct(protected KoboProxyConfiguration $configuration, protected LoggerInterface $proxyLogger) + public function __construct( + protected KoboProxyConfiguration $configuration, + protected LoggerInterface $koboProxyLogger) { } public function create(string $accessToken): KoboProxyLogger { - return new KoboProxyLogger($this->configuration, $this->proxyLogger, $accessToken); + return new KoboProxyLogger($this->configuration, $this->koboProxyLogger, $accessToken); } public function createStack(string $accessToken): HandlerStack diff --git a/src/Kobo/Proxy/KoboStoreProxy.php b/src/Kobo/Proxy/KoboStoreProxy.php index 55a1bfbe..69504756 100644 --- a/src/Kobo/Proxy/KoboStoreProxy.php +++ b/src/Kobo/Proxy/KoboStoreProxy.php @@ -4,6 +4,7 @@ use App\Security\KoboTokenExtractor; use GuzzleHttp\Client; +use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Promise\PromiseInterface; use Nyholm\Psr7\Factory\Psr17Factory; @@ -26,8 +27,13 @@ class KoboStoreProxy { use KoboHeaderFilterTrait; - public function __construct(protected KoboProxyLoggerFactory $koboProxyLoggerFactory, protected KoboProxyConfiguration $configuration, protected LoggerInterface $proxyLogger, protected KoboTokenExtractor $tokenExtractor) - { + public function __construct( + protected KoboProxyLoggerFactory $koboProxyLoggerFactory, + protected KoboProxyConfiguration $configuration, + protected LoggerInterface $koboProxyLogger, + protected KoboTokenExtractor $tokenExtractor, + protected ?ClientInterface $client = null, + ) { } protected function assertEnabled(): void @@ -44,7 +50,7 @@ public function proxy(Request $request, array $options = []): Response { $this->assertEnabled(); - $url = $this->configuration->isImageHostUrl($request) ? $this->configuration->getImageApiUrl() : $this->configuration->getStoreApiUrl(); + $url = $this->getUpstreamUrl($request); return $this->_proxy($request, $url, $options); } @@ -89,12 +95,10 @@ private function _proxy(Request $request, string $hostname, array $config = []): $config = $this->getConfig($config); $psrRequest = $this->convertRequest($request, $hostname); - $accessToken = $this->tokenExtractor->extractAccessToken($request) ?? 'unknown'; + $client = $this->getClient($request); - $client = new Client(); $psrResponse = $client->send($psrRequest, [ 'base_uri' => $hostname, - 'handler' => $this->koboProxyLoggerFactory->createStack($accessToken), 'http_errors' => false, 'connect_timeout' => 5, ] + $config @@ -106,16 +110,16 @@ private function _proxy(Request $request, string $hostname, array $config = []): protected function getTransformedUrl(Request $request): UriInterface { $psrRequest = $this->toPsrRequest($request); - $hostname = $this->configuration->isImageHostUrl($psrRequest) ? $this->configuration->getImageApiUrl() : $this->configuration->getStoreApiUrl(); + $upstreamUrl = $this->getUpstreamUrl($request); - return $this->transformUrl($psrRequest, $hostname); + return $this->transformUrl($psrRequest, $upstreamUrl); } - private function transformUrl(ServerRequestInterface $psrRequest, string $hostname): UriInterface + private function transformUrl(ServerRequestInterface $psrRequest, string $hostnameOrUrl): UriInterface { - $host = parse_url($hostname, PHP_URL_HOST); - $host = $host === false ? $hostname : $host; - $host = $host ?? $hostname; + $host = parse_url($hostnameOrUrl, PHP_URL_HOST); + $host = $host === false ? $hostnameOrUrl : $host; + $host = $host ?? $hostnameOrUrl; $path = $this->tokenExtractor->getOriginalPath($psrRequest, $psrRequest->getUri()->getPath()); return $psrRequest->getUri()->withHost($host)->withPath($path); @@ -131,15 +135,16 @@ private function toPsrRequest(Request $request): ServerRequestInterface public function proxyAsync(Request $request, bool $streamAllowed): PromiseInterface { - $hostname = $this->configuration->isImageHostUrl($request) ? $this->configuration->getImageApiUrl() : $this->configuration->getStoreApiUrl(); - $psrRequest = $this->convertRequest($request, $hostname); + $upstreamUrl = $this->getUpstreamUrl($request); + + $psrRequest = $this->convertRequest($request, $upstreamUrl); $accessToken = $this->tokenExtractor->extractAccessToken($request) ?? 'unknown'; - $client = new Client(); + $client = $this->getClient($request); return $client->sendAsync($psrRequest, [ - 'base_uri' => $hostname, + 'base_uri' => $upstreamUrl, 'handler' => $this->koboProxyLoggerFactory->createStack($accessToken), 'http_errors' => false, 'connect_timeout' => 5, @@ -167,10 +172,19 @@ private function convertResponse(ResponseInterface $psrResponse, bool $streamAll { $httpFoundationFactory = new HttpFoundationFactory(); - $response = $httpFoundationFactory->createResponse($psrResponse, $streamAllowed); - $this->cleanupResponse($response); + $psrResponse = $this->cleanupPsrResponse($psrResponse); + + return $httpFoundationFactory->createResponse($psrResponse, $streamAllowed); + } + + private function getUpstreamUrl(Request $request): string + { + $url = $this->configuration->isImageHostUrl($request) ? $this->configuration->getImageApiUrl() : $this->configuration->getStoreApiUrl(); + if ($this->configuration->isReadingServiceUrl($request)) { + $url = $this->configuration->getReadingServiceUrl(); + } - return $response; + return $url; } private function getConfig(array $config): array @@ -183,4 +197,22 @@ private function getConfig(array $config): array return $config; } + + public function setClient(?ClientInterface $client): void + { + $this->client = $client; + } + + private function getClient(Request $request): ClientInterface + { + if ($this->client instanceof ClientInterface) { + return $this->client; + } + + $accessToken = $this->tokenExtractor->extractAccessToken($request) ?? 'unknown'; + + return new Client([ + 'handler' => $this->koboProxyLoggerFactory->createStack($accessToken), + ]); + } } diff --git a/src/Kobo/Response/MetadataResponseService.php b/src/Kobo/Response/MetadataResponseService.php index 13e7398d..5bd99093 100644 --- a/src/Kobo/Response/MetadataResponseService.php +++ b/src/Kobo/Response/MetadataResponseService.php @@ -19,7 +19,7 @@ class MetadataResponseService public function __construct( protected DownloadHelper $downloadHelper, protected KepubifyEnabler $kepubifyEnabler, - protected LoggerInterface $koboLogger, + protected LoggerInterface $koboKepubifyLogger, ) { } @@ -41,7 +41,7 @@ protected function getDownloadUrls(Book $book, KoboDevice $kobo, ?array $filters 'Platform' => $platform, ]]; } catch (KepubifyConversionFailed $e) { - $this->koboLogger->info('Conversion failed for book {book}', ['book' => $book->getUuid(), 'exception' => $e]); + $this->koboKepubifyLogger->info('Conversion failed for book {book}', ['book' => $book->getUuid(), 'exception' => $e]); } } @@ -92,7 +92,7 @@ public function fromBook(Book $book, KoboDevice $kobo, ?SyncToken $syncToken = n // Add Serie information $data['Series'] = [ - 'Name' => $book->getSerieIndex(), + 'Name' => $book->getSerie(), 'Number' => (int) $book->getSerieIndex(), 'NumberFloat' => $book->getSerieIndex(), 'Id' => md5($book->getSerie()), // Get a deterministic id based on the series name. diff --git a/src/Kobo/Response/ReadingStateResponse.php b/src/Kobo/Response/ReadingStateResponse.php index 1a665670..d89d0a0c 100644 --- a/src/Kobo/Response/ReadingStateResponse.php +++ b/src/Kobo/Response/ReadingStateResponse.php @@ -22,16 +22,16 @@ public function __construct( } /** - * @return array + * @return array> */ public function createReadingState(): array { $book = $this->book; $uuid = $book->getUuid(); - $lastModified = $this->syncToken->maxLastModified($book->getUpdated(), $this->syncToken->currentDate, $book->getLastInteraction($this->kobo->getUser())?->getUpdated()); + $lastModified = $this->syncToken->maxLastModified($this->kobo->getUser()->getBookmarkForBook($book)?->getUpdated(), $book->getUpdated(), $this->syncToken->currentDate, $book->getLastInteraction($this->kobo->getUser())?->getUpdated()); - return [ + return [[ 'EntitlementId' => $uuid, 'Created' => $this->syncToken->maxLastCreated($book->getCreated(), $this->syncToken->currentDate, $book->getLastInteraction($this->kobo->getUser())?->getCreated()), 'LastModified' => $lastModified, @@ -48,7 +48,7 @@ public function createReadingState(): array // "Statistics"=> get_statistics_response(kobo_reading_state.statistics), 'CurrentBookmark' => $this->createBookmark($this->kobo->getUser()->getBookmarkForBook($book)), - ]; + ]]; } /** diff --git a/src/Kobo/Response/ReadingStateResponseFactory.php b/src/Kobo/Response/ReadingStateResponseFactory.php index 7236a5c6..57138ee4 100644 --- a/src/Kobo/Response/ReadingStateResponseFactory.php +++ b/src/Kobo/Response/ReadingStateResponseFactory.php @@ -16,8 +16,8 @@ class ReadingStateResponseFactory public function __construct( protected MetadataResponseService $metadataResponseService, protected BookProgressionService $bookProgressionService, - protected SerializerInterface $serializer) - { + protected SerializerInterface $serializer, + ) { } public function create(SyncToken $syncToken, KoboDevice $kobo, Book $book): ReadingStateResponse diff --git a/src/Kobo/Response/StateResponse.php b/src/Kobo/Response/StateResponse.php index c720fd5c..525e703b 100644 --- a/src/Kobo/Response/StateResponse.php +++ b/src/Kobo/Response/StateResponse.php @@ -8,10 +8,10 @@ class StateResponse extends JsonResponse { - public function __construct(Book $book) + public function __construct(Book $book, bool $isSuccess = true) { parent::__construct([ - 'RequestResult' => 'Success', + 'RequestResult' => $isSuccess ? 'Success' : 'FailedCommands', 'UpdateResults' => [ [ 'CurrentBookmarkResult' => [ @@ -22,7 +22,7 @@ public function __construct(Book $book) 'Result' => 'Success', ], 'StatusInfoResult' => [ - 'Result' => 'Success', + 'Result' => $isSuccess ? 'Success' : 'Conflict', ], ], ], diff --git a/src/Kobo/Response/SyncResponse.php b/src/Kobo/Response/SyncResponse.php index e9d96cc5..e341a608 100644 --- a/src/Kobo/Response/SyncResponse.php +++ b/src/Kobo/Response/SyncResponse.php @@ -16,8 +16,10 @@ /** * @phpstan-type BookEntitlement array * @phpstan-type BookMetadata array - * @phpstan-type BookReadingState array + * @phpstan-type BookReadingState array> * @phpstan-type BookTag array + * @phpstan-type RemoteItem array + * @phpstan-type RemoteItems array */ class SyncResponse { @@ -33,6 +35,11 @@ class SyncResponse public const READING_STATUS_IN_PROGRESS = 'Reading'; private SyncResponseHelper $helper; + /** + * @var RemoteItems + */ + private array $remoteItems = []; + public function __construct( protected MetadataResponseService $metadataResponse, protected BookProgressionService $bookProgressionService, @@ -52,6 +59,9 @@ public function toJsonResponse(): JsonResponse array_push($list, ...$this->getChangedReadingState()); array_push($list, ...$this->getNewTags()); array_push($list, ...$this->getChangedTag()); + + $list = array_merge($list, $this->remoteItems); + array_filter($list); $response = new JsonResponse(); @@ -217,10 +227,13 @@ private function createBookTagFromShelf(Shelf $shelf): array private function createBookEntitlement(Book $book): array { + $rs = $this->createReadingState($book); + $rs = reset($rs); + return [ 'BookEntitlement' => $this->createEntitlement($book), 'BookMetadata' => $this->metadataResponse->fromBook($book, $this->kobo, $this->syncToken), - 'ReadingState' => $this->createReadingState($book), + 'ReadingState' => $rs, ]; } @@ -235,9 +248,21 @@ private function getChangedReadingState(): array return array_map(function (Book $book) { $response = new \stdClass(); - $response->ChangedReadingState = $this->createReadingState($book); + $rs = $this->createReadingState($book); + $rs = reset($rs); + $response->ChangedReadingState = $rs; return $response; }, $books); } + + /** + * @param RemoteItems $items + */ + public function addRemoteItems(array $items): self + { + $this->remoteItems = array_merge($this->remoteItems, $items); + + return $this; + } } diff --git a/src/Kobo/UpstreamSyncMerger.php b/src/Kobo/UpstreamSyncMerger.php new file mode 100644 index 00000000..325235d9 --- /dev/null +++ b/src/Kobo/UpstreamSyncMerger.php @@ -0,0 +1,85 @@ +isUpstreamSync() || false === $this->koboStoreProxy->isEnabled()) { + $this->koboSyncLogger->debug('Your device {device} has "upstream sync" disabled', [ + 'device' => $device->getId(), + ]); + + return false; + } + + try { + $response = $this->koboStoreProxy->proxy($request, ['stream' => false]); + } catch (GuzzleException $e) { + $this->koboSyncLogger->error('Unable to sync with upstream: {exception}', [ + 'exception' => $e, + ]); + + return false; + } + if (false === $response->isOk()) { + $this->koboSyncLogger->error('Sync response is not ok. Got '.$response->getStatusCode()); + + return false; + } + + $json = $this->parseJson($response); + + if ($json === []) { + return false; + } + + $this->koboSyncLogger->info('Merging {count} upstream items', [ + 'device' => $device->getId(), + 'count' => count($json), + ]); + $syncResponse->addRemoteItems($json); + + return $this->shouldContinue($response); + } + + private function parseJson(Response $response): array + { + try { + $result = $response->getContent(); + if (false === $result) { + throw new \RuntimeException('Response content is false. Code: '.$response->getStatusCode()); + } + + return (array) json_decode($result, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $e) { + $this->koboSyncLogger->warning('Unable to upstream sync response: {content}', [ + 'exception' => $e, + 'content' => $response->getContent(), + ]); + + return []; + } + } + + private function shouldContinue(Response $response): bool + { + return $response->headers->get('x-kobo-sync') === 'continue'; + } +} diff --git a/src/Service/BookFileSystemManager.php b/src/Service/BookFileSystemManager.php index 86e9dcb5..1e13aae0 100644 --- a/src/Service/BookFileSystemManager.php +++ b/src/Service/BookFileSystemManager.php @@ -22,12 +22,12 @@ class BookFileSystemManager public const VALID_COVER_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']; public function __construct( - private Security $security, - private string $publicDir, - private string $bookFolderNamingFormat, - private string $bookFileNamingFormat, - private SluggerInterface $slugger, - private LoggerInterface $logger) + private readonly Security $security, + private readonly string $publicDir, + private readonly string $bookFolderNamingFormat, + private readonly string $bookFileNamingFormat, + private readonly SluggerInterface $slugger, + private readonly LoggerInterface $logger) { if ($this->bookFolderNamingFormat === '') { throw new \RuntimeException('Could not get filename format'); diff --git a/templates/kobodevice_user/logs.html.twig b/templates/kobodevice_user/logs.html.twig index 6d1f9a8d..edd7ebde 100644 --- a/templates/kobodevice_user/logs.html.twig +++ b/templates/kobodevice_user/logs.html.twig @@ -7,6 +7,8 @@ Date + Device + channel Message @@ -14,7 +16,13 @@ {% for record in records %} - {{ record.datetime|date('d.m.Y') }} {{ record.datetime|date('H:i:s') }} + {{ record.datetime|date('d.m.Y') }} {{ record.datetime|date('H:i:s') }} + + + {{ record.extra.kobo|default('unkown') }} + + + {{ record.channel }}
diff --git a/tests/Contraints/JSONIsValidSyncResponse.php b/tests/Contraints/JSONIsValidSyncResponse.php index 5f8fc32a..13f35946 100644 --- a/tests/Contraints/JSONIsValidSyncResponse.php +++ b/tests/Contraints/JSONIsValidSyncResponse.php @@ -20,7 +20,15 @@ public function __construct(protected array $expectedKeysCount) } } - const KNOWN_TYPES = ["NewEntitlement", "ChangedTag", "NewTag", "RemovedPublication", "ChangedEntitlement"]; + const KNOWN_TYPES = [ + "ChangedEntitlement", + "ChangedReadingState", + "ChangedTag", + "DeletedTag", + "NewEntitlement", + "NewTag", + "RemovedPublication", + ]; public function matches($other): bool{ try{ $this->test($other); @@ -49,6 +57,8 @@ private function test(mixed $other): void "NewEntitlement" => $this->assertNewEntitlement($item['NewEntitlement']), "ChangedTag" => $this->assertChangedTag(), "NewTag" => $this->assertNewTag(), + "DeletedTag" => $this->assertDeletedTag(), + "ChangedReadingState" => null, "RemovedPublication" => $this->assertRemovedPublication(), "ChangedEntitlement" => $this->assertChangedEntitlement($item['ChangedEntitlement']), default => throw new \InvalidArgumentException('Unknown type') @@ -71,6 +81,9 @@ private function assertChangedTag(): void private function assertNewTag(): void { } + private function assertDeletedTag(): void + { + } private function assertRemovedPublication(): void { diff --git a/tests/Controller/Kobo/AbstractKoboControllerTest.php b/tests/Controller/Kobo/AbstractKoboControllerTest.php index 14676dec..b946bd6a 100644 --- a/tests/Controller/Kobo/AbstractKoboControllerTest.php +++ b/tests/Controller/Kobo/AbstractKoboControllerTest.php @@ -3,7 +3,13 @@ namespace App\Tests\Controller\Kobo; use App\Kobo\Kepubify\KepubifyEnabler; +use App\Kobo\Proxy\KoboProxyConfiguration; +use App\Kobo\Proxy\KoboStoreProxy; use App\Tests\InjectFakeFileSystemTrait; +use GuzzleHttp\Client; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; use Symfony\Component\BrowserKit\AbstractBrowser; use App\DataFixtures\BookFixture; use App\Entity\Book; @@ -90,5 +96,27 @@ protected function getKepubifyEnabler(): KepubifyEnabler return $service; } + protected function getKoboStoreProxy(): KoboStoreProxy + { + $service = self::getContainer()->get(KoboStoreProxy::class); + assert($service instanceof KoboStoreProxy); + + return $service; + } + protected function getKoboProxyConfiguration(): KoboProxyConfiguration + { + $service = self::getContainer()->get(KoboProxyConfiguration::class); + assert($service instanceof KoboProxyConfiguration); + return $service; + } + protected function getMockClient(string $returnValue): ClientInterface + { + $mock = new MockHandler([ + new \GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/json'], $returnValue), + ]); + + $handlerStack = HandlerStack::create($mock); + return new Client(['handler' => $handlerStack]); + } } \ No newline at end of file diff --git a/tests/Controller/Kobo/KoboSyncControllerTest.php b/tests/Controller/Kobo/KoboSyncControllerTest.php index 9338d0d4..be03568d 100644 --- a/tests/Controller/Kobo/KoboSyncControllerTest.php +++ b/tests/Controller/Kobo/KoboSyncControllerTest.php @@ -9,7 +9,14 @@ class KoboSyncControllerTest extends AbstractKoboControllerTest { + protected function tearDown(): void + { + $this->getKoboStoreProxy()->setClient(null); + $this->getKoboProxyConfiguration()->setEnabled(false); + $this->getEntityManager()->getRepository(KoboSyncedBook::class)->deleteAllSyncedBooks(1); + parent::tearDown(); + } public function assertPreConditions(): void { $count = $this->getEntityManager()->getRepository(KoboSyncedBook::class)->count(['koboDevice' => 1]); @@ -24,6 +31,8 @@ public function testSyncControllerWithForce() : void $client = static::getClient(); $this->injectFakeFileSystemManager(); + $this->getEntityManager()->getRepository(KoboSyncedBook::class)->deleteAllSyncedBooks(1); + $client?->request('GET', '/kobo/'.$this->accessKey.'/v1/library/sync?force=1'); $this->getEntityManager()->getRepository(KoboSyncedBook::class)->deleteAllSyncedBooks(1); @@ -61,6 +70,43 @@ public function testSyncControllerWithoutForce() : void } + public function testSyncControllerWithRemote() : void + { + $client = static::getClient(); + + // Enable remote sync + $this->getKoboDevice()->setUpstreamSync(true); + $this->getKoboProxyConfiguration()->setEnabled(true); + $this->getEntityManager()->flush(); + $this->getKoboDevice(true); + + $this->getKoboStoreProxy()->setClient($this->getMockClient('[{ + "DeletedTag": { + "Tag": { + "Id": "28521096-ed64-4709-a043-781a0ed0695f", + "LastModified": "2024-02-02T13:35:31.0000000Z" + } + } + }]')); + + $this->injectFakeFileSystemManager(); + + $client?->request('GET', '/kobo/'.$this->accessKey.'/v1/library/sync'); + + $response = self::getJsonResponse(); + self::assertResponseIsSuccessful(); + self::assertThat($response, new JSONIsValidSyncResponse([ + 'NewEntitlement' => 1, + 'NewTag' => 1, + 'DeletedTag' => 1 + ]), 'Response is not a valid sync response'); + + + $this->getEntityManager()->getRepository(KoboSyncedBook::class)->deleteAllSyncedBooks(1); + $this->getKoboDevice()->setUpstreamSync(false); + $this->getEntityManager()->flush(); + } + public function testSyncControllerMetadata() : void { $uuid = $this->getBook()->getUuid();