From df3eb40e3c351aaaaece84629dc0f7396e72c41b Mon Sep 17 00:00:00 2001 From: Michael Shavit Date: Mon, 17 Feb 2020 14:37:02 -0500 Subject: [PATCH] Make KoboStore proxying more robust. * Add a timeout to prevent hanging when the KoboStore isn't reachable. * Add back a the dummy auth implementation for when proxying is disabled. * Return the dummy auth response as a fallback when failing to contact the KoboStore. * Don't contact the KoboStore during the /sync API call when proxying is disabled. --- cps/kobo.py | 175 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 107 insertions(+), 68 deletions(-) diff --git a/cps/kobo.py b/cps/kobo.py index d58be3c5..b3c72076 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -73,8 +73,26 @@ CONNECTION_SPECIFIC_HEADERS = [ def get_kobo_activated(): return config.config_kobo_sync -def redirect_or_proxy_request(proxy=False): - if config.config_kobo_proxy or proxy == True: + +def make_request_to_kobo_store(sync_token=None): + outgoing_headers = Headers(request.headers) + outgoing_headers.remove("Host") + if sync_token: + sync_token.set_kobo_store_header(outgoing_headers) + + store_response = requests.request( + method=request.method, + url=get_store_url_for_current_request(), + headers=outgoing_headers, + data=request.get_data(), + allow_redirects=False, + timeout=(2, 10) + ) + return store_response + + +def redirect_or_proxy_request(): + if config.config_kobo_proxy: if request.method == "GET": return redirect(get_store_url_for_current_request(), 307) if request.method == "DELETE": @@ -82,15 +100,7 @@ def redirect_or_proxy_request(proxy=False): return make_response(jsonify({})) else: # The Kobo device turns other request types into GET requests on redirects, so we instead proxy to the Kobo store ourselves. - outgoing_headers = Headers(request.headers) - outgoing_headers.remove("Host") - store_response = requests.request( - method=request.method, - url=get_store_url_for_current_request(), - headers=outgoing_headers, - data=request.get_data(), - allow_redirects=False, - ) + store_response = make_request_to_kobo_store() response_headers = store_response.headers for header_key in CONNECTION_SPECIFIC_HEADERS: @@ -102,6 +112,7 @@ def redirect_or_proxy_request(proxy=False): else: return make_response(jsonify({})) + @kobo.route("/v1/library/sync") @requires_kobo_auth @download_required @@ -160,35 +171,24 @@ def HandleSyncRequest(): def generate_sync_response(request, sync_token, entitlements): - # We first merge in sync results from the official Kobo store. - outgoing_headers = Headers(request.headers) - outgoing_headers.remove("Host") - sync_token.set_kobo_store_header(outgoing_headers) - store_response = requests.request( - method=request.method, - url=get_store_url_for_current_request(), - headers=outgoing_headers, - data=request.get_data(), - ) + extra_headers = {} + if config.config_kobo_proxy: + # Merge in sync results from the official Kobo store. + try: + store_response = make_request_to_kobo_store(sync_token) + + store_entitlements = store_response.json() + entitlements += store_entitlements + sync_token.merge_from_store_response(store_response) + extra_headers["x-kobo-sync"] = store_response.headers.get("x-kobo-sync") + extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode") + extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads") - store_entitlements = store_response.json() - entitlements += store_entitlements - sync_token.merge_from_store_response(store_response) - - response = make_response(jsonify(entitlements)) - - sync_token.to_headers(response.headers) - try: - # These headers could probably use some more investigation. - response.headers["x-kobo-sync"] = store_response.headers["x-kobo-sync"] - response.headers["x-kobo-sync-mode"] = store_response.headers[ - "x-kobo-sync-mode" - ] - response.headers["x-kobo-recent-reads"] = store_response.headers[ - "x-kobo-recent-reads" - ] - except KeyError: - pass + except Exception as e: + log.error("Failed to receive or parse response from Kobo's sync endpoint: " + str(e)) + sync_token.to_headers(extra_headers) + + response = make_response(jsonify(entitlements), extra_headers) return response @@ -377,6 +377,7 @@ def HandleUnimplementedRequest(dummy=None, book_uuid=None, shelf_name=None, tag_ log.debug("Alternative Request received:") return redirect_or_proxy_request() + # TODO: Implement the following routes @kobo.route("/v1/user/loyalty/", methods=["GET", "POST"]) @kobo.route("/v1/user/profile", methods=["GET", "POST"]) @@ -384,9 +385,21 @@ def HandleUnimplementedRequest(dummy=None, book_uuid=None, shelf_name=None, tag_ @kobo.route("/v1/user/recommendations", methods=["GET", "POST"]) @kobo.route("/v1/analytics/", methods=["GET", "POST"]) def HandleUserRequest(dummy=None): - log.debug("Unimplemented Request received: %s", request.base_url) + log.debug("Unimplemented User Request received: %s", request.base_url) + return redirect_or_proxy_request() + + +@kobo.route("/v1/products//prices", methods=["GET", "POST"]) +@kobo.route("/v1/products//recommendations", methods=["GET", "POST"]) +@kobo.route("/v1/products//nextread", methods=["GET", "POST"]) +@kobo.route("/v1/products//reviews", methods=["GET", "POST"]) +@kobo.route("/v1/products/books/", methods=["GET", "POST"]) +@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"]) +def HandleProductsRequest(dummy=None): + log.debug("Unimplemented Products Request received: %s", request.base_url) return redirect_or_proxy_request() + @kobo.app_errorhandler(404) def handle_404(err): # This handler acts as a catch-all for endpoints that we don't have an interest in @@ -394,14 +407,45 @@ def handle_404(err): log.debug("Unknown Request received: %s", request.base_url) return redirect_or_proxy_request() + +def make_calibre_web_auth_response(): + # As described in kobo_auth.py, CalibreWeb doesn't make use practical use of this auth/device API call for + # authentation (nor for authorization). We return a dummy response just to keep the device happy. + return make_response( + jsonify( + { + "AccessToken": "abcde", + "RefreshToken": "abcde", + "TokenType": "Bearer", + "TrackingId": "abcde", + "UserKey": "abcdefgeh", + } + ) + ) + @kobo.route("/v1/auth/device", methods=["POST"]) -def login_auth_token(): +def HandleAuthRequest(): log.info('Auth') - return redirect_or_proxy_request(proxy=True) + if config.config_kobo_proxy: + try: + return redirect_or_proxy_request() + except: + log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.") + return make_calibre_web_auth_response() + + +def make_calibre_web_init_response(calibre_web_url): + resources = NATIVE_KOBO_RESOURCES(calibre_web_url) + response = make_response(jsonify({"Resources": resources})) + response.headers["x-kobo-apitoken"] = "e30=" + return response + @kobo.route("/v1/initialization") @requires_kobo_auth def HandleInitRequest(): + log.info('Init') + if not current_app.wsgi_app.is_proxied: log.debug('Kobo: Received unproxied request, changed request port to server port') calibre_web_url = "{url_scheme}://{url_base}:{url_port}".format( @@ -411,34 +455,29 @@ def HandleInitRequest(): ) else: calibre_web_url = url_for("web.index", _external=True).strip("/") - if config.config_kobo_proxy: - outgoing_headers = Headers(request.headers) - outgoing_headers.remove("Host") - store_response = requests.request( - method=request.method, - url=get_store_url_for_current_request(), - headers=outgoing_headers, - data=request.get_data(), - ) - store_response_json = store_response.json() - if "Resources" in store_response_json: - kobo_resources = store_response_json["Resources"] - # calibre_web_url = url_for("web.index", _external=True).strip("/") - kobo_resources["image_host"] = calibre_web_url - kobo_resources["image_url_quality_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest", - auth_token = kobo_auth.get_auth_token(), - book_uuid="{ImageId}")) - kobo_resources["image_url_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest", - auth_token = kobo_auth.get_auth_token(), - book_uuid="{ImageId}")) + if config.config_kobo_proxy: + try: + store_response = make_request_to_kobo_store() + + store_response_json = store_response.json() + if "Resources" in store_response_json: + kobo_resources = store_response_json["Resources"] + # calibre_web_url = url_for("web.index", _external=True).strip("/") + kobo_resources["image_host"] = calibre_web_url + kobo_resources["image_url_quality_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest", + auth_token = kobo_auth.get_auth_token(), + book_uuid="{ImageId}")) + kobo_resources["image_url_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest", + auth_token = kobo_auth.get_auth_token(), + book_uuid="{ImageId}")) + + return make_response(store_response_json, store_response.status_code) + except: + log.error("Failed to receive or parse response from Kobo's init endpoint. Falling back to un-proxied mode.") + + return make_calibre_web_init_response(calibre_web_url) - return make_response(store_response_json, store_response.status_code) - else: - resources = NATIVE_KOBO_RESOURCES(calibre_web_url) - response = make_response(jsonify({"Resources": resources})) - response.headers["x-kobo-apitoken"] = "e30=" - return response def NATIVE_KOBO_RESOURCES(calibre_web_url): return {