From f705889c2353b82f3da28e41c58e6e77c3162d23 Mon Sep 17 00:00:00 2001 From: Ozzieisaacs Date: Tue, 17 Dec 2019 18:00:35 +0100 Subject: [PATCH] Inital Kobo --- cps.py | 3 + cps/admin.py | 14 + cps/config_sql.py | 1 + cps/db.py | 6 +- cps/helper.py | 26 +- cps/kobo.py | 523 +++++++++++++++++++++++++++++++++ cps/kobo_auth.py | 102 +++++++ cps/templates/config_edit.html | 4 + cps/templates/user_edit.html | 4 + cps/ub.py | 13 +- cps/web.py | 4 +- requirements.txt | 1 + 12 files changed, 688 insertions(+), 13 deletions(-) create mode 100644 cps/kobo.py create mode 100644 cps/kobo_auth.py diff --git a/cps.py b/cps.py index ca7d7230..412604d2 100755 --- a/cps.py +++ b/cps.py @@ -41,6 +41,8 @@ from cps.shelf import shelf from cps.admin import admi from cps.gdrive import gdrive from cps.editbooks import editbook +from cps.kobo import kobo + try: from cps.oauth_bb import oauth oauth_available = True @@ -58,6 +60,7 @@ def main(): app.register_blueprint(admi) app.register_blueprint(gdrive) app.register_blueprint(editbook) + app.register_blueprint(kobo) if oauth_available: app.register_blueprint(oauth) success = web_server.start() diff --git a/cps/admin.py b/cps/admin.py index 57796080..50e0589d 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -299,6 +299,8 @@ def _configuration_update_helper(): reboot_required |= _config_string("config_certfile") if config.config_certfile and not os.path.isfile(config.config_certfile): return _configuration_result('Certfile location is not valid, please enter correct path', gdriveError) + + _config_string("config_server_url") _config_checkbox_int("config_uploading") _config_checkbox_int("config_anonbrowse") @@ -597,6 +599,18 @@ def edit_user(user_id): content.default_language = to_save["default_language"] if "locale" in to_save and to_save["locale"]: content.locale = to_save["locale"] + + if "kobo_user_key" in to_save and to_save["kobo_user_key"]: + kobo_user_key_hash = generate_password_hash(to_save["kobo_user_key"]) + if kobo_user_key_hash != content.kobo_user_key_hash: + existing_kobo_user_key = ub.session.query(ub.User).filter(ub.User.kobo_user_key_hash == kobo_user_key_hash).first() + if not existing_kobo_user_key: + content.kobo_user_key_hash = kobo_user_key_hash + else: + flash(_(u"Found an existing account for this Kobo UserKey."), category="error") + return render_title_template("user_edit.html", translations=translations, languages=languages, + new_user=0, content=content, downloads=downloads, registered_oauth=oauth_check, + title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") if to_save["email"] and to_save["email"] != content.email: existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \ .first() diff --git a/cps/config_sql.py b/cps/config_sql.py index 809e97d8..8ea8b978 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -49,6 +49,7 @@ class _Settings(_Base): config_port = Column(Integer, default=constants.DEFAULT_PORT) config_certfile = Column(String) config_keyfile = Column(String) + config_server_url = Column(String, default='') config_calibre_web_title = Column(String, default=u'Calibre-Web') config_books_per_page = Column(Integer, default=60) diff --git a/cps/db.py b/cps/db.py index b9853896..5765bf68 100755 --- a/cps/db.py +++ b/cps/db.py @@ -25,7 +25,7 @@ import ast from sqlalchemy import create_engine from sqlalchemy import Table, Column, ForeignKey -from sqlalchemy import String, Integer, Boolean +from sqlalchemy import String, Integer, Boolean, TIMESTAMP from sqlalchemy.orm import relationship, sessionmaker, scoped_session from sqlalchemy.ext.declarative import declarative_base @@ -251,10 +251,10 @@ class Books(Base): title = Column(String) sort = Column(String) author_sort = Column(String) - timestamp = Column(String) + timestamp = Column(TIMESTAMP) pubdate = Column(String) series_index = Column(String) - last_modified = Column(String) + last_modified = Column(TIMESTAMP) path = Column(String) has_cover = Column(Integer) uuid = Column(String) diff --git a/cps/helper.py b/cps/helper.py index 2b92ef75..e5e616d5 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -446,32 +446,46 @@ def delete_book(book, calibrepath, book_format): return delete_book_file(book, calibrepath, book_format) +def get_cover_on_failure(use_generic_cover): + if use_generic_cover: + return send_from_directory(_STATIC_DIR, "generic_cover.jpg") + else: + return None + def get_book_cover(book_id): book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - if book.has_cover: + return get_book_cover_internal(book, use_generic_cover_on_failure=True) + +def get_book_cover_with_uuid(book_uuid, + use_generic_cover_on_failure=True): + book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first() + return get_book_cover_internal(book, use_generic_cover_on_failure) +def get_book_cover_internal(book, + use_generic_cover_on_failure): + if book and book.has_cover: if config.config_use_google_drive: try: if not gd.is_gdrive_ready(): - return send_from_directory(_STATIC_DIR, "generic_cover.jpg") + return get_cover_on_failure(use_generic_cover_on_failure) path=gd.get_cover_via_gdrive(book.path) if path: return redirect(path) else: log.error('%s/cover.jpg not found on Google Drive', book.path) - return send_from_directory(_STATIC_DIR, "generic_cover.jpg") + return get_cover_on_failure(use_generic_cover_on_failure) except Exception as e: log.exception(e) # traceback.print_exc() - return send_from_directory(_STATIC_DIR,"generic_cover.jpg") + return get_cover_on_failure(use_generic_cover_on_failure) else: cover_file_path = os.path.join(config.config_calibre_dir, book.path) if os.path.isfile(os.path.join(cover_file_path, "cover.jpg")): return send_from_directory(cover_file_path, "cover.jpg") else: - return send_from_directory(_STATIC_DIR,"generic_cover.jpg") + return get_cover_on_failure(use_generic_cover_on_failure) else: - return send_from_directory(_STATIC_DIR,"generic_cover.jpg") + return get_cover_on_failure(use_generic_cover_on_failure) # saves book cover from url diff --git a/cps/kobo.py b/cps/kobo.py new file mode 100644 index 00000000..270f5c33 --- /dev/null +++ b/cps/kobo.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import sys +import uuid +from base64 import b64decode, b64encode +from datetime import datetime +from time import gmtime, strftime + +from jsonschema import validate, exceptions +from flask import Blueprint, request, make_response, jsonify, json +from flask_login import login_required +from sqlalchemy import func + +from . import config, logger, kobo_auth, db, helper +from .web import download_required + +kobo = Blueprint("kobo", __name__) +kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo) + +log = logger.create() + + +def b64encode_json(json_data): + if sys.version_info < (3, 0): + return b64encode(json.dumps(json_data)) + else: + return b64encode(json.dumps(json_data).encode()) + + +# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2. +def to_epoch_timestamp(datetime_object): + return (datetime_object - datetime(1970, 1, 1)).total_seconds() + + +class SyncToken: + """ The SyncToken is used to persist state accross requests. + When serialized over the response headers, the Kobo device will propagate the token onto following requests to the service. + As an example use-case, the SyncToken is used to detect books that have been added to the library since the last time the device synced to the server. + + Attributes: + books_last_created: Datetime representing the newest book that the device knows about. + books_last_modified: Datetime representing the last modified book that the device knows about. + """ + + SYNC_TOKEN_HEADER = "x-kobo-synctoken" + VERSION = "1-0-0" + MIN_VERSION = "1-0-0" + + token_schema = { + "type": "object", + "properties": {"version": {"type": "string"}, "data": {"type": "object"},}, + } + # This Schema doesn't contain enough information to detect and propagate book deletions from Calibre to the device. + # A potential solution might be to keep a list of all known book uuids in the token, and look for any missing from the db. + data_schema_v1 = { + "type": "object", + "properties": { + "raw_kobo_store_token": {"type": "string"}, + "books_last_modified": {"type": "string"}, + "books_last_created": {"type": "string"}, + }, + } + + def __init__( + self, + raw_kobo_store_token="", + books_last_created=datetime.min, + books_last_modified=datetime.min, + ): + self.raw_kobo_store_token = raw_kobo_store_token + self.books_last_created = books_last_created + self.books_last_modified = books_last_modified + + @staticmethod + def from_headers(headers): + sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "") + if sync_token_header == "": + return SyncToken() + + # On the first sync from a Kobo device, we may receive the SyncToken + # from the official Kobo store. Without digging too deep into it, that + # token is of the form [b64encoded blob].[b64encoded blob 2] + if "." in sync_token_header: + return SyncToken(raw_kobo_store_token=sync_token_header) + + try: + sync_token_json = json.loads( + b64decode(sync_token_header + "=" * (-len(sync_token_header) % 4)) + ) + validate(sync_token_json, SyncToken.token_schema) + if sync_token_json["version"] < SyncToken.MIN_VERSION: + raise ValueError + + data_json = sync_token_json["data"] + validate(sync_token_json, SyncToken.data_schema_v1) + except (exceptions.ValidationError, ValueError) as e: + log.error("Sync token contents do not follow the expected json schema.") + return SyncToken() + + raw_kobo_store_token = data_json["raw_kobo_store_token"] + try: + books_last_modified = datetime.utcfromtimestamp( + data_json["books_last_modified"] + ) + books_last_created = datetime.utcfromtimestamp( + data_json["books_last_created"] + ) + except TypeError: + log.error("SyncToken timestamps don't parse to a datetime.") + return SyncToken(raw_kobo_store_token=raw_kobo_store_token) + + return SyncToken( + raw_kobo_store_token=raw_kobo_store_token, + books_last_created=books_last_created, + books_last_modified=books_last_modified, + ) + + def to_headers(self, headers): + headers[SyncToken.SYNC_TOKEN_HEADER] = self.build_sync_token() + + def build_sync_token(self): + token = { + "version": SyncToken.VERSION, + "data": { + "raw_kobo_store_token": self.raw_kobo_store_token, + "books_last_modified": to_epoch_timestamp(self.books_last_modified), + "books_last_created": to_epoch_timestamp(self.books_last_created), + }, + } + return b64encode_json(token) + + +@kobo.route("/v1/library/sync") +@login_required +@download_required +def HandleSyncRequest(): + sync_token = SyncToken.from_headers(request.headers) + log.info("Kobo library sync request received.") + + # TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header + # instead so that the device triggers another sync. + + new_books_last_modified = sync_token.books_last_modified + new_books_last_created = sync_token.books_last_created + entitlements = [] + + # sqlite gives unexpected results when performing the last_modified comparison without the datetime cast. + # It looks like it's treating the db.Books.last_modified field as a string and may fail + # the comparison because of the +00:00 suffix. + changed_entries = ( + db.session.query(db.Books) + .filter(func.datetime(db.Books.last_modified) != sync_token.books_last_modified) + .all() + ) + for book in changed_entries: + entitlement = { + "BookEntitlement": create_book_entitlement(book), + "BookMetadata": get_metadata(book), + "ReadingState": reading_state(book), + } + if book.timestamp > sync_token.books_last_created: + entitlements.append({"NewEntitlement": entitlement}) + else: + entitlements.append({"ChangedEntitlement": entitlement}) + + new_books_last_modified = max( + book.last_modified, sync_token.books_last_modified + ) + new_books_last_created = max(book.timestamp, sync_token.books_last_modified) + + sync_token.books_last_created = new_books_last_created + sync_token.books_last_modified = new_books_last_modified + + # Missing feature: Detect server-side book deletions. + + # Missing feature: Join the response with results from the official Kobo store so that users can still buy and access books from the device store (particularly while on-the-road). + + response = make_response(jsonify(entitlements)) + + sync_token.to_headers(response.headers) + response.headers["x-kobo-sync-mode"] = "delta" + response.headers["x-kobo-apitoken"] = "e30=" + return response + + +@kobo.route("/v1/library//metadata") +@login_required +@download_required +def HandleMetadataRequest(book_uuid): + log.info("Kobo library metadata request received for book %s" % book_uuid) + book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first() + if not book or not book.data: + log.info(u"Book %s not found in database", book_uuid) + return make_response("Book not found in database.", 404) + + metadata = get_metadata(book) + return jsonify([metadata]) + + +def get_download_url_for_book(book, book_format): + return "{url_base}/download/{book_id}/{book_format}".format( + url_base=request.environ['werkzeug.request'].base_url, + book_id=book.id, + book_format=book_format.lower(), + ) + + +def create_book_entitlement(book): + book_uuid = book.uuid + return { + "Accessibility": "Full", + "ActivePeriod": {"From": current_time(),}, + "Created": book.timestamp, + "CrossRevisionId": book_uuid, + "Id": book_uuid, + "IsHiddenFromArchive": False, + "IsLocked": False, + # Setting this to true removes from the device. + "IsRemoved": False, + "LastModified": book.last_modified, + "OriginCategory": "Imported", + "RevisionId": book_uuid, + "Status": "Active", + } + + +def current_time(): + return strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) + + +def get_description(book): + if not book.comments: + return None + return book.comments[0].text + + +# TODO handle multiple authors +def get_author(book): + if not book.authors: + return None + return book.authors[0].name + + +def get_publisher(book): + if not book.publishers: + return None + return book.publishers[0].name + + +def get_series(book): + if not book.series: + return None + return book.series[0].name + + +def get_metadata(book): + ALLOWED_FORMATS = {"KEPUB"} + download_urls = [] + + for book_data in book.data: + if book_data.format in ALLOWED_FORMATS: + download_urls.append( + { + "Format": book_data.format, + "Size": book_data.uncompressed_size, + "Url": get_download_url_for_book(book, book_data.format), + # "DrmType": "None", # Not required + "Platform": "Android", # Required field. + } + ) + + book_uuid = book.uuid + metadata = { + "Categories": ["00000000-0000-0000-0000-000000000001",], + "Contributors": get_author(book), + "CoverImageId": book_uuid, + "CrossRevisionId": book_uuid, + "CurrentDisplayPrice": {"CurrencyCode": "USD", "TotalAmount": 0}, + "CurrentLoveDisplayPrice": {"TotalAmount": 0}, + "Description": get_description(book), + "DownloadUrls": download_urls, + "EntitlementId": book_uuid, + "ExternalIds": [], + "Genre": "00000000-0000-0000-0000-000000000001", + "IsEligibleForKoboLove": False, + "IsInternetArchive": False, + "IsPreOrder": False, + "IsSocialEnabled": True, + "Language": "en", + "PhoneticPronunciations": {}, + "PublicationDate": "2019-02-03T00:25:03.0000000Z", # current_time(), + "Publisher": {"Imprint": "", "Name": get_publisher(book),}, + "RevisionId": book_uuid, + "Title": book.title, + "WorkId": book_uuid, + } + + if get_series(book): + if sys.version_info < (3, 0): + name = get_series(book).encode("utf-8") + else: + name = get_series(book) + metadata["Series"] = { + "Name": get_series(book), + "Number": book.series_index, + "NumberFloat": float(book.series_index), + # Get a deterministic id based on the series name. + "Id": uuid.uuid3(uuid.NAMESPACE_DNS, name), + } + + return metadata + + +def reading_state(book): + # TODO: Implement + reading_state = { + # "StatusInfo": { + # "LastModified": get_single_cc_value(book, "lastreadtimestamp"), + # "Status": get_single_cc_value(book, "reading_status"), + # } + # TODO: CurrentBookmark, Location + } + return reading_state + + +@kobo.route( + "//////image.jpg" +) +def HandleCoverImageRequest(book_uuid, horizontal, vertical, jpeg_quality, monochrome): + book_cover = helper.get_book_cover_with_uuid( + book_uuid, use_generic_cover_on_failure=False + ) + if not book_cover: + return make_response() + return book_cover + + +@kobo.route("/v1/user/profile") +@kobo.route("/v1/user/loyalty/benefits") +@kobo.route("/v1/analytics/gettests", methods=["GET", "POST"]) +@kobo.route("/v1/user/wishlist") +@kobo.route("/v1/user/") +@kobo.route("/v1/user/recommendations") +@kobo.route("/v1/products/") +@kobo.route("/v1/products//nextread") +@kobo.route("/v1/products/featured/") +@kobo.route("/v1/products/featured/") +@kobo.route("/v1/library/", methods=["DELETE", "GET"]) # TODO: implement +def HandleDummyRequest(dummy=None): + return make_response(jsonify({})) + + +@kobo.route("/v1/auth/device", methods=["POST"]) +def HandleAuthRequest(): + # This AuthRequest isn't used for most of our usecases. + response = make_response( + jsonify( + { + "AccessToken": "abcde", + "RefreshToken": "abcde", + "TokenType": "Bearer", + "TrackingId": "abcde", + "UserKey": "abcdefgeh", + } + ) + ) + return response + + +@kobo.route("/v1/initialization") +def HandleInitRequest(): + resources = NATIVE_KOBO_RESOURCES(calibre_web_url=config.config_server_url) + response = make_response(jsonify({"Resources": resources})) + response.headers["x-kobo-apitoken"] = "e30=" + return response + + +def NATIVE_KOBO_RESOURCES(calibre_web_url): + return { + "account_page": "https://secure.kobobooks.com/profile", + "account_page_rakuten": "https://my.rakuten.co.jp/", + "add_entitlement": "https://storeapi.kobo.com/v1/library/{RevisionIds}", + "affiliaterequest": "https://storeapi.kobo.com/v1/affiliate", + "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"}, + "book": "https://storeapi.kobo.com/v1/products/books/{ProductId}", + "book_detail_page": "https://store.kobobooks.com/{culture}/ebook/{slug}", + "book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}", + "book_landing_page": "https://store.kobobooks.com/ebooks", + "book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions", + "categories": "https://storeapi.kobo.com/v1/categories", + "categories_page": "https://store.kobobooks.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", + "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://kbdownload1-a.akamaihd.net", + "discovery_host": "https://discovery.kobobooks.com", + "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/", + "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", + "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", + "help_page": "http://www.kobo.com/help", + "image_host": calibre_web_url, + "image_url_quality_template": calibre_web_url + + "/{ImageId}/{Width}/{Height}/{Quality}/{IsGreyscale}/image.jpg", + "image_url_template": calibre_web_url + + "/{ImageId}/{Width}/{Height}/false/image.jpg", + "kobo_audiobooks_enabled": "False", + "kobo_audiobooks_orange_deal_enabled": "False", + "kobo_audiobooks_subscriptions_enabled": "False", + "kobo_nativeborrow_enabled": "True", + "kobo_onestorelibrary_enabled": "False", + "kobo_redeem_enabled": "True", + "kobo_shelfie_enabled": "False", + "kobo_subscriptions_enabled": "False", + "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_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", + "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", + "post_analytics_event": "https://storeapi.kobo.com/v1/analytics/event", + "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", + "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}", + "quickbuy_checkout": "https://storeapi.kobo.com/v1/store/quickbuy/{PurchaseId}/checkout", + "quickbuy_create": "https://storeapi.kobo.com/v1/store/quickbuy/purchase", + "rating": "https://storeapi.kobo.com/v1/products/{ProductId}/rating/{Rating}", + "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/", + "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/", + "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", + "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", + "update_accessibility_to_preview": "https://storeapi.kobo.com/v1/library/{EntitlementIds}/preview", + "use_one_store": "False", + "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://kbdownload1-a.akamaihd.net", + "wishlist_page": "https://store.kobobooks.com/{region}/{language}/account/wishlist", + } diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py new file mode 100644 index 00000000..1504c25b --- /dev/null +++ b/cps/kobo_auth.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +"""This module is used to control authentication/authorization of Kobo sync requests. +This module also includes research notes into the auth protocol used by Kobo devices. + +Log-in: +When first booting a Kobo device the user must sign into a Kobo (or affiliate) account. +Upon successful sign-in, the user is redirected to + https://auth.kobobooks.com/CrossDomainSignIn?id= +which serves the following response: + . +And triggers the insertion of a userKey into the device's User table. + +IMPORTANT SECURITY CAUTION: +Together, the device's DeviceId and UserKey act as an *irrevocable* authentication +token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is +required to authorize the API call. + +Changing Kobo password *does not* invalidate user keys! This is apparently a known +issue for a few years now https://www.mobileread.com/forums/showpost.php?p=3476851&postcount=13 +(although this poster hypothesised that Kobo could blacklist a DeviceId, many endpoints +will still grant access given the userkey.) + +Official Kobo Store Api authorization: +* For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is +passed in the x-kobo-userkey header, and is sufficient to authorize the API call. +* Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens pass through +an authorization header. To get a BearerToken, the device makes a POST request to the +v1/auth/device endpoint with the secret UserKey and the device's DeviceId. +* The book download endpoint passes an auth token as a URL param instead of a header. + +Our implementation: +For now, we rely on the official Kobo store's UserKey for authentication. +Once authenticated, we set the login cookie on the response that will be sent back for +the duration of the session to authorize subsequent API calls. +Ideally we'd only perform UserKey-based authentication for the v1/initialization or the +v1/device/auth call, however sessions don't always start with those calls. + +Because of the irrevocable power granted by the key, we only ever store and compare a +hash of the key. To obtain their UserKey, a user can query the user table from the +.kobo/KoboReader.sqlite database found on their device. +This isn't exactly user friendly however. + +Some possible alternatives that require more research: + * Instead of having users query the device database to find out their UserKey, we could + provide a list of recent Kobo sync attempts in the calibre-web UI for users to + authenticate sync attempts (e.g: 'this was me' button). + * We may be able to craft a sign-in flow with a redirect back to the CalibreWeb + server containing the KoboStore's UserKey (if the same as the devices?). + * Can we create our own UserKey instead of relying on the real store's userkey? + (Maybe using something like location.href=kobo://UserAuthenticated?userId=...?) +""" + +from functools import wraps +from flask import request, make_response +from flask_login import login_user +from werkzeug.security import check_password_hash + +from . import logger, ub, lm + +USER_KEY_HEADER = "x-kobo-userkey" +USER_KEY_URL_PARAM = "kobo_userkey" + +log = logger.create() + + +def disable_failed_auth_redirect_for_blueprint(bp): + lm.blueprint_login_views[bp.name] = None + + +@lm.request_loader +def load_user_from_kobo_request(request): + user_key = request.headers.get(USER_KEY_HEADER) + if user_key: + for user in ( + ub.session.query(ub.User).filter(ub.User.kobo_user_key_hash != "").all() + ): + if check_password_hash(str(user.kobo_user_key_hash), user_key): + # The Kobo device won't preserve the cookie accross sessions, even if we + # were to set remember_me=true. + login_user(user) + return user + log.info("Received Kobo request without a recognizable UserKey.") + return None diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 85b9598e..9556eef4 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -104,6 +104,10 @@ +
+ + +
diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html index e4e36c64..5ace1eab 100644 --- a/cps/templates/user_edit.html +++ b/cps/templates/user_edit.html @@ -28,6 +28,10 @@
+
+ + +