calibre-web/cps/kobo.py

837 lines
36 KiB
Python
Raw Normal View History

2019-12-17 18:00:35 +01:00
#!/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 <http://www.gnu.org/licenses/>.
import datetime
2019-12-17 18:00:35 +01:00
import sys
import base64
import os
2019-12-17 18:00:35 +01:00
import uuid
from time import gmtime, strftime
2019-12-30 15:16:09 +01:00
try:
from urllib import unquote
except ImportError:
from urllib.parse import unquote
2019-12-17 18:00:35 +01:00
2019-12-30 15:16:09 +01:00
from flask import (
Blueprint,
request,
make_response,
jsonify,
2020-01-26 14:42:53 +01:00
current_app,
2019-12-30 15:16:09 +01:00
url_for,
redirect,
abort
2019-12-30 15:16:09 +01:00
)
2020-03-13 00:59:05 +01:00
from flask_login import current_user, login_required
2019-12-30 15:16:09 +01:00
from werkzeug.datastructures import Headers
from sqlalchemy import func
2020-03-13 00:59:05 +01:00
from sqlalchemy.sql.expression import and_, or_
2019-12-30 15:16:09 +01:00
import requests
2019-12-17 18:00:35 +01:00
from . import config, logger, kobo_auth, db, helper, ub
2020-01-20 06:14:53 +01:00
from .services import SyncToken as SyncToken
2019-12-17 18:00:35 +01:00
from .web import download_required
from .kobo_auth import requires_kobo_auth
2019-12-17 18:00:35 +01:00
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
2019-12-30 15:16:09 +01:00
KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
2019-12-20 07:04:12 +01:00
kobo = Blueprint("kobo", __name__, url_prefix="/kobo/<auth_token>")
2019-12-17 18:00:35 +01:00
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
kobo_auth.register_url_value_preprocessor(kobo)
2019-12-17 18:00:35 +01:00
log = logger.create()
2019-12-30 15:16:09 +01:00
def get_store_url_for_current_request():
# Programmatically modify the current url to point to the official Kobo store
base, sep, request_path_with_auth_token = request.full_path.rpartition("/kobo/")
auth_token, sep, request_path = request_path_with_auth_token.rstrip("?").partition(
"/"
)
return KOBO_STOREAPI_URL + "/" + request_path
CONNECTION_SPECIFIC_HEADERS = [
"connection",
"content-encoding",
"content-length",
"transfer-encoding",
]
2020-01-27 20:32:37 +01:00
def get_kobo_activated():
return config.config_kobo_sync
2019-12-30 15:16:09 +01:00
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)
else:
# The Kobo device turns other request types into GET requests on redirects, so we instead proxy to the Kobo store ourselves.
store_response = make_request_to_kobo_store()
2019-12-30 15:16:09 +01:00
response_headers = store_response.headers
for header_key in CONNECTION_SPECIFIC_HEADERS:
response_headers.pop(header_key, default=None)
2019-12-30 15:16:09 +01:00
return make_response(
store_response.content, store_response.status_code, response_headers.items()
)
else:
return make_response(jsonify({}))
2019-12-30 15:16:09 +01:00
def convert_to_kobo_timestamp_string(timestamp):
return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
2019-12-17 18:00:35 +01:00
@kobo.route("/v1/library/sync")
@requires_kobo_auth
2019-12-17 18:00:35 +01:00
@download_required
def HandleSyncRequest():
2020-01-20 06:14:53 +01:00
sync_token = SyncToken.SyncToken.from_headers(request.headers)
2019-12-17 18:00:35 +01:00
log.info("Kobo library sync request received.")
if not current_app.wsgi_app.is_proxied:
log.debug('Kobo: Received unproxied request, changed request port to server port')
2019-12-17 18:00:35 +01:00
# 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
new_reading_state_last_modified = sync_token.reading_state_last_modified
sync_results = []
2019-12-17 18:00:35 +01:00
2019-12-30 15:16:09 +01:00
# We reload the book database so that the user get's a fresh view of the library
# in case of external changes (e.g: adding a book through Calibre).
db.reconnect_db(config)
archived_books = (
ub.session.query(ub.ArchivedBook)
.filter(ub.ArchivedBook.user_id == int(current_user.id))
.all()
)
# We join-in books that have had their Archived bit recently modified in order to either:
# * Restore them to the user's device.
# * Delete them from the user's device.
# (Ideally we would use a join for this logic, however cross-database joins don't look trivial in SqlAlchemy.)
recently_restored_or_archived_books = []
archived_book_ids = {}
2020-03-13 00:59:05 +01:00
new_archived_last_modified = datetime.datetime.min
for archived_book in archived_books:
if archived_book.last_modified > sync_token.archive_last_modified:
recently_restored_or_archived_books.append(archived_book.book_id)
if archived_book.is_archived:
archived_book_ids[archived_book.book_id] = True
new_archived_last_modified = max(
new_archived_last_modified, archived_book.last_modified)
2019-12-17 18:00:35 +01:00
# 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)
.join(db.Data)
.filter(or_(func.datetime(db.Books.last_modified) > sync_token.books_last_modified,
db.Books.id.in_(recently_restored_or_archived_books)))
2019-12-30 15:16:09 +01:00
.filter(db.Data.format.in_(KOBO_FORMATS))
2019-12-17 18:00:35 +01:00
.all()
)
2020-01-26 14:42:53 +01:00
reading_states_in_new_entitlements = []
2019-12-17 18:00:35 +01:00
for book in changed_entries:
kobo_reading_state = get_or_create_reading_state(book.id)
2019-12-17 18:00:35 +01:00
entitlement = {
"BookEntitlement": create_book_entitlement(book, archived=(book.id in archived_book_ids)),
2019-12-17 18:00:35 +01:00
"BookMetadata": get_metadata(book),
}
if kobo_reading_state.last_modified > sync_token.reading_state_last_modified:
entitlement["ReadingState"] = get_kobo_reading_state_response(book, kobo_reading_state)
new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
reading_states_in_new_entitlements.append(book.id)
2019-12-17 18:00:35 +01:00
if book.timestamp > sync_token.books_last_created:
sync_results.append({"NewEntitlement": entitlement})
2019-12-17 18:00:35 +01:00
else:
sync_results.append({"ChangedEntitlement": entitlement})
2019-12-17 18:00:35 +01:00
new_books_last_modified = max(
book.last_modified, new_books_last_modified
2019-12-17 18:00:35 +01:00
)
new_books_last_created = max(book.timestamp, new_books_last_created)
2019-12-17 18:00:35 +01:00
changed_reading_states = (
ub.session.query(ub.KoboReadingState)
.filter(and_(func.datetime(ub.KoboReadingState.last_modified) > sync_token.reading_state_last_modified,
ub.KoboReadingState.user_id == current_user.id,
ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements))))
for kobo_reading_state in changed_reading_states.all():
book = db.session.query(db.Books).filter(db.Books.id == kobo_reading_state.book_id).one()
sync_results.append({
"ChangedReadingState": {
"ReadingState": get_kobo_reading_state_response(book, kobo_reading_state)
}
})
new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
2019-12-17 18:00:35 +01:00
sync_token.books_last_created = new_books_last_created
sync_token.books_last_modified = new_books_last_modified
sync_token.archive_last_modified = new_archived_last_modified
2020-03-13 00:59:05 +01:00
sync_token.reading_state_last_modified = new_reading_state_last_modified
2019-12-17 18:00:35 +01:00
if config.config_kobo_proxy:
return generate_sync_response(request, sync_token, sync_results)
2019-12-30 15:16:09 +01:00
return make_response(jsonify(sync_results))
2019-12-17 18:00:35 +01:00
# Missing feature: Detect server-side book deletions.
2019-12-30 15:16:09 +01:00
def generate_sync_response(request, sync_token, sync_results):
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_sync_results = store_response.json()
sync_results += store_sync_results
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")
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(sync_results), extra_headers)
2019-12-30 15:16:09 +01:00
2019-12-17 18:00:35 +01:00
return response
@kobo.route("/v1/library/<book_uuid>/metadata")
@requires_kobo_auth
2019-12-17 18:00:35 +01:00
@download_required
def HandleMetadataRequest(book_uuid):
if not current_app.wsgi_app.is_proxied:
log.debug('Kobo: Received unproxied request, changed request port to server port')
2019-12-17 18:00:35 +01:00
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)
2019-12-30 15:16:09 +01:00
return redirect_or_proxy_request()
2019-12-17 18:00:35 +01:00
metadata = get_metadata(book)
return jsonify([metadata])
def get_download_url_for_book(book, book_format):
2020-01-26 14:42:53 +01:00
if not current_app.wsgi_app.is_proxied:
2020-03-09 20:25:59 +01:00
if ':' in request.host and not request.host.endswith(']') :
host = "".join(request.host.split(':')[:-1])
else:
host = request.host
return "{url_scheme}://{url_base}:{url_port}/download/{book_id}/{book_format}".format(
url_scheme=request.scheme,
2020-03-09 20:25:59 +01:00
url_base=host,
url_port=config.config_port,
2020-01-26 14:42:53 +01:00
book_id=book.id,
book_format=book_format.lower()
2020-01-26 14:42:53 +01:00
)
return url_for(
"web.download_link",
book_id=book.id,
book_format=book_format.lower(),
_external=True,
)
2019-12-17 18:00:35 +01:00
def create_book_entitlement(book, archived):
2019-12-17 18:00:35 +01:00
book_uuid = book.uuid
return {
"Accessibility": "Full",
"ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.datetime.now())},
"Created": convert_to_kobo_timestamp_string(book.timestamp),
2019-12-17 18:00:35 +01:00
"CrossRevisionId": book_uuid,
"Id": book_uuid,
"IsRemoved": archived,
2019-12-17 18:00:35 +01:00
"IsHiddenFromArchive": False,
"IsLocked": False,
2020-03-13 00:59:05 +01:00
"LastModified": convert_to_kobo_timestamp_string(book.last_modified),
2019-12-17 18:00:35 +01:00
"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):
download_urls = []
for book_data in book.data:
2019-12-30 15:16:09 +01:00
if book_data.format not in KOBO_FORMATS:
continue
for kobo_format in KOBO_FORMATS[book_data.format]:
2020-01-26 14:42:53 +01:00
# log.debug('Id: %s, Format: %s' % (book.id, kobo_format))
2019-12-17 18:00:35 +01:00
download_urls.append(
{
2019-12-30 15:16:09 +01:00
"Format": kobo_format,
2019-12-17 18:00:35 +01:00
"Size": book_data.uncompressed_size,
"Url": get_download_url_for_book(book, book_data.format),
2019-12-30 15:16:09 +01:00
# The Kobo forma accepts platforms: (Generic, Android)
"Platform": "Generic",
2019-12-17 18:00:35 +01:00
# "DrmType": "None", # Not required
}
)
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": {},
# TODO: Fix book.pubdate to return a datetime object so that we can easily
# convert it to the format Kobo devices expect.
2019-12-30 15:16:09 +01:00
"PublicationDate": book.pubdate,
2019-12-17 18:00:35 +01:00
"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
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
@login_required
def HandleStateRequest(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 redirect_or_proxy_request()
kobo_reading_state = get_or_create_reading_state(book.id)
if request.method == "GET":
return jsonify([get_kobo_reading_state_response(book, kobo_reading_state)])
else:
update_results_response = {"EntitlementId": book_uuid}
request_data = request.json
if "ReadingStates" not in request_data:
abort(400, description="Malformed request data is missing 'ReadingStates' key")
request_reading_state = request_data["ReadingStates"][0]
request_bookmark = request_reading_state.get("CurrentBookmark")
if request_bookmark:
current_bookmark = kobo_reading_state.current_bookmark
current_bookmark.progress_percent = request_bookmark.get("ProgressPercent")
current_bookmark.content_source_progress_percent = request_bookmark.get("ContentSourceProgressPercent")
location = request_bookmark.get("Location")
if location:
current_bookmark.location_value = location.get("Value")
current_bookmark.location_type = location.get("Type")
current_bookmark.location_source = location.get("Source")
update_results_response["CurrentBookmarkResult"] = {"Result": "Success"}
request_statistics = request_reading_state.get("Statistics")
if request_statistics:
statistics = kobo_reading_state.statistics
statistics.spent_reading_minutes = request_statistics.get("SpentReadingMinutes")
statistics.remaining_time_minutes = request_statistics.get("RemainingTimeMinutes")
update_results_response["StatisticsResult"] = {"Result": "Success"}
request_status_info = request_reading_state.get("StatusInfo")
if request_status_info:
book_read = kobo_reading_state.book_read_link
new_book_read_status = get_ub_read_status(request_status_info.get("Status"))
if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS and new_book_read_status != book_read.read_status:
book_read.times_started_reading += 1
book_read.last_time_started_reading = datetime.datetime.utcnow()
book_read.read_status = new_book_read_status
update_results_response["StatusInfoResult"] = {"Result": "Success"}
ub.session.merge(kobo_reading_state)
ub.session.commit()
return jsonify({
"RequestResult": "Success",
"UpdateResults": [update_results_response],
})
def get_read_status_for_kobo(ub_book_read):
enum_to_string_map = {
None: "ReadyToRead",
ub.ReadBook.STATUS_UNREAD: "ReadyToRead",
ub.ReadBook.STATUS_FINISHED: "Finished",
ub.ReadBook.STATUS_IN_PROGRESS: "Reading",
}
return enum_to_string_map[ub_book_read.read_status]
def get_ub_read_status(kobo_read_status):
string_to_enum_map = {
None: None,
"ReadyToRead": ub.ReadBook.STATUS_UNREAD,
"Finished": ub.ReadBook.STATUS_FINISHED,
"Reading": ub.ReadBook.STATUS_IN_PROGRESS,
2019-12-17 18:00:35 +01:00
}
return string_to_enum_map[kobo_read_status]
def get_or_create_reading_state(book_id):
book_read = ub.session.query(ub.ReadBook).filter(and_(ub.ReadBook.book_id == book_id,
ub.ReadBook.user_id == current_user.id)).one_or_none()
if not book_read:
book_read = ub.ReadBook(user_id=current_user.id, book_id=book_id)
if not book_read.kobo_reading_state:
kobo_reading_state = ub.KoboReadingState(user_id=book_read.user_id, book_id=book_id)
kobo_reading_state.current_bookmark = ub.KoboBookmark()
kobo_reading_state.statistics = ub.KoboStatistics()
book_read.kobo_reading_state = kobo_reading_state
ub.session.add(book_read)
ub.session.commit()
return book_read.kobo_reading_state
def get_kobo_reading_state_response(book, kobo_reading_state):
return {
"EntitlementId": book.uuid,
"Created": convert_to_kobo_timestamp_string(book.timestamp),
"LastModified": convert_to_kobo_timestamp_string(kobo_reading_state.last_modified),
# AFAICT PriorityTimestamp is always equal to LastModified.
"PriorityTimestamp": convert_to_kobo_timestamp_string(kobo_reading_state.priority_timestamp),
"StatusInfo": get_status_info_response(kobo_reading_state.book_read_link),
"Statistics": get_statistics_response(kobo_reading_state.statistics),
"CurrentBookmark": get_current_bookmark_response(kobo_reading_state.current_bookmark),
}
def get_status_info_response(book_read):
resp = {
"LastModified": convert_to_kobo_timestamp_string(book_read.last_modified),
"Status": get_read_status_for_kobo(book_read),
"TimesStartedReading": book_read.times_started_reading,
}
if book_read.last_time_started_reading:
resp["LastTimeStartedReading"] = convert_to_kobo_timestamp_string(book_read.last_time_started_reading)
return resp
def get_statistics_response(statistics):
resp = {
"LastModified": convert_to_kobo_timestamp_string(statistics.last_modified),
}
if statistics.spent_reading_minutes:
resp["SpentReadingMinutes"] = statistics.spent_reading_minutes
if statistics.remaining_time_minutes:
resp["RemainingTimeMinutes"] = statistics.remaining_time_minutes
return resp
def get_current_bookmark_response(current_bookmark):
resp = {
"LastModified": convert_to_kobo_timestamp_string(current_bookmark.last_modified),
}
if current_bookmark.progress_percent:
resp["ProgressPercent"] = current_bookmark.progress_percent
if current_bookmark.content_source_progress_percent:
resp["ContentSourceProgressPercent"] = current_bookmark.content_source_progress_percent
if current_bookmark.location_value:
resp["Location"] = {
"Value": current_bookmark.location_value,
"Type": current_bookmark.location_type,
"Source": current_bookmark.location_source,
}
return resp
2019-12-17 18:00:35 +01:00
2020-01-20 06:14:53 +01:00
@kobo.route("/<book_uuid>/image.jpg")
@requires_kobo_auth
2019-12-30 15:16:09 +01:00
def HandleCoverImageRequest(book_uuid):
2019-12-17 18:00:35 +01:00
book_cover = helper.get_book_cover_with_uuid(
book_uuid, use_generic_cover_on_failure=False
)
if not book_cover:
if config.config_kobo_proxy:
2020-03-09 20:25:59 +01:00
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
return redirect(get_store_url_for_current_request(), 307)
else:
2020-03-09 20:25:59 +01:00
log.debug("Cover for unknown book: %s requested" % book_uuid)
return redirect_or_proxy_request()
log.debug("Cover request received for book %s" % book_uuid)
2019-12-17 18:00:35 +01:00
return book_cover
@kobo.route("")
def TopLevelEndpoint():
return make_response(jsonify({}))
2019-12-20 07:04:12 +01:00
@kobo.route("/v1/library/<book_uuid>", methods=["DELETE"])
@login_required
def HandleBookDeletionRequest(book_uuid):
log.info("Kobo book deletion request received for book %s" % book_uuid)
book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
if not book:
log.info(u"Book %s not found in database", book_uuid)
return redirect_or_proxy_request()
book_id = book.id
archived_book = (
ub.session.query(ub.ArchivedBook)
.filter(ub.ArchivedBook.book_id == book_id)
.first()
)
if not archived_book:
archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
archived_book.is_archived = True
2020-03-13 00:59:05 +01:00
archived_book.last_modified = datetime.datetime.utcnow()
ub.session.merge(archived_book)
ub.session.commit()
return ("", 204)
2019-12-30 15:16:09 +01:00
# TODO: Implement the following routes
2020-03-13 00:59:05 +01:00
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
2019-12-30 15:16:09 +01:00
@kobo.route("/v1/library/tags", methods=["POST"])
@kobo.route("/v1/library/tags/<shelf_name>", methods=["POST"])
@kobo.route("/v1/library/tags/<tag_id>", methods=["DELETE"])
2020-03-13 00:59:05 +01:00
def HandleUnimplementedRequest(dummy=None, book_uuid=None, shelf_name=None, tag_id=None):
log.debug("Unimplemented Library Request received: %s", request.base_url)
2019-12-30 15:16:09 +01:00
return redirect_or_proxy_request()
2019-12-17 18:00:35 +01:00
2020-01-26 14:42:53 +01:00
# TODO: Implement the following routes
@kobo.route("/v1/user/loyalty/<dummy>", methods=["GET", "POST"])
@kobo.route("/v1/user/profile", methods=["GET", "POST"])
@kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
@kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
def HandleUserRequest(dummy=None):
log.debug("Unimplemented User Request received: %s", request.base_url)
return redirect_or_proxy_request()
2019-12-17 18:00:35 +01:00
@kobo.route("/v1/products/<dummy>/prices", methods=["GET", "POST"])
@kobo.route("/v1/products/<dummy>/recommendations", methods=["GET", "POST"])
@kobo.route("/v1/products/<dummy>/nextread", methods=["GET", "POST"])
@kobo.route("/v1/products/<dummy>/reviews", methods=["GET", "POST"])
@kobo.route("/v1/products/books/<dummy>", methods=["GET", "POST"])
@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"])
@kobo.route("/v1/products", methods=["GET", "POST"])
def HandleProductsRequest(dummy=None):
log.debug("Unimplemented Products Request received: %s", request.base_url)
return redirect_or_proxy_request()
2019-12-30 15:16:09 +01:00
@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
# implementing (e.g: v1/analytics/gettests, v1/user/recommendations, etc)
2020-01-26 14:42:53 +01:00
log.debug("Unknown Request received: %s", request.base_url)
2019-12-30 15:16:09 +01:00
return redirect_or_proxy_request()
2019-12-17 18:00:35 +01:00
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.
content = request.get_json()
AccessToken = base64.b64encode(os.urandom(24)).decode('utf-8')
RefreshToken = base64.b64encode(os.urandom(24)).decode('utf-8')
2020-02-22 16:24:06 +01:00
return make_response(
jsonify(
{
"AccessToken": AccessToken,
"RefreshToken": RefreshToken,
"TokenType": "Bearer",
"TrackingId": str(uuid.uuid4()),
"UserKey": content['UserKey'],
}
)
2020-02-22 16:24:06 +01:00
)
@kobo.route("/v1/auth/device", methods=["POST"])
@requires_kobo_auth
def HandleAuthRequest():
log.debug('Kobo Auth request')
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
2019-12-17 18:00:35 +01:00
@kobo.route("/v1/initialization")
@requires_kobo_auth
2019-12-17 18:00:35 +01:00
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')
2020-03-09 20:25:59 +01:00
if ':' in request.host and not request.host.endswith(']'):
host = "".join(request.host.split(':')[:-1])
else:
host = request.host
calibre_web_url = "{url_scheme}://{url_base}:{url_port}".format(
url_scheme=request.scheme,
2020-03-09 20:25:59 +01:00
url_base=host,
url_port=config.config_port
)
else:
2019-12-30 15:16:09 +01:00
calibre_web_url = url_for("web.index", _external=True).strip("/")
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)
2020-01-26 14:42:53 +01:00
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": unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
auth_token = kobo_auth.get_auth_token(),
book_uuid="{ImageId}")),
"image_url_template": unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest",
auth_token = kobo_auth.get_auth_token(),
book_uuid="{ImageId}")),
2020-01-26 14:42:53 +01:00
"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",
}