Commit f705889c authored by Ozzieisaacs's avatar Ozzieisaacs

Inital Kobo

parent 7098d088
......@@ -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()
......
......@@ -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()
......
......@@ -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)
......
......@@ -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)
......
......@@ -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
......
This diff is collapsed.
#!/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/>.
"""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=<some id>
which serves the following response:
<script type='text/javascript'>location.href='kobo://UserAuthenticated?userId=<redacted>&userKey<redacted>&email=<redacted>&returnUrl=https%3a%2f%2fwww.kobo.com';</script>.
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
......@@ -104,6 +104,10 @@
<!--option-- value="3" {% if config.config_updatechannel == 3 %}selected{% endif %}>{{_('Nightly (Automatic)')}}</option-->
</select>
</div>
<div class="form-group">
<label for="config_server_url">{{_('Server Url. This is only used for the (experimental) Kobo device library sync')}}</label>
<input type="text" class="form-control" name="config_server_url" id="config_server_url" value="{% if config.config_server_url != None %}{{ config.config_server_url }}{% endif %}" autocomplete="off">
</div>
</div>
</div>
</div>
......
......@@ -28,6 +28,10 @@
<input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}">
</div>
<div class="form-group">
<div class="form-group">
<label for="kobo_user_key">{{_('KoboStore UserKey')}}</label>
<input type="password" class="form-control" name="kobo_user_key" id="kobo_user_key" value="" autocomplete="off">
</div>
<label for="locale">{{_('Language')}}</label>
<select name="locale" id="locale" class="form-control">
{% for translation in translations %}
......
......@@ -173,6 +173,7 @@ class User(UserBase, Base):
role = Column(SmallInteger, default=constants.ROLE_USER)
password = Column(String)
kindle_mail = Column(String(120), default="")
kobo_user_key_hash = Column(String, unique=True, default="")
shelf = relationship('Shelf', backref='user', lazy='dynamic', order_by='Shelf.name')
downloads = relationship('Downloads', backref='user', lazy='dynamic')
locale = Column(String(2), default="en")
......@@ -375,7 +376,12 @@ def migrate_Database(session):
except exc.OperationalError:
conn = engine.connect()
conn.execute("ALTER TABLE user ADD column `mature_content` INTEGER DEFAULT 1")
try:
session.query(exists().where(User.kobo_user_key_hash)).scalar()
except exc.OperationalError: # Database is not compatible, some rows are missing
conn = engine.connect()
conn.execute("ALTER TABLE user ADD column `kobo_user_key_hash` VARCHAR")
session.commit()
if session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS).first() is None:
create_anonymous_user(session)
try:
......@@ -391,6 +397,7 @@ def migrate_Database(session):
"role SMALLINT,"
"password VARCHAR,"
"kindle_mail VARCHAR(120),"
"kobo_user_key_hash VARCHAR,"
"locale VARCHAR(2),"
"sidebar_view INTEGER,"
"default_language VARCHAR(3),"
......@@ -398,9 +405,9 @@ def migrate_Database(session):
"UNIQUE (nickname),"
"UNIQUE (email),"
"CHECK (mature_content IN (0, 1)))")
conn.execute("INSERT INTO user_id(id, nickname, email, role, password, kindle_mail,locale,"
conn.execute("INSERT INTO user_id(id, nickname, email, role, password, kindle_mail,kobo_user_key_hash, locale,"
"sidebar_view, default_language, mature_content) "
"SELECT id, nickname, email, role, password, kindle_mail, locale,"
"SELECT id, nickname, email, role, password, kindle_mail, kobo_user_key_hash, locale,"
"sidebar_view, default_language, mature_content FROM user")
# delete old user table and rename new user_id table to user:
conn.execute("DROP TABLE user")
......
......@@ -1288,6 +1288,8 @@ def profile():
current_user.password = generate_password_hash(to_save["password"])
if "kindle_mail" in to_save and to_save["kindle_mail"] != current_user.kindle_mail:
current_user.kindle_mail = to_save["kindle_mail"]
if "kobo_user_key" in to_save and to_save["kobo_user_key"]:
current_user.kobo_user_key_hash = generate_password_hash(to_save["kobo_user_key"])
if to_save["email"] and to_save["email"] != current_user.email:
if config.config_public_reg and not check_valid_domain(to_save["email"]):
flash(_(u"E-mail is not from valid domain"), category="error")
......@@ -1331,7 +1333,7 @@ def profile():
ub.session.commit()
except IntegrityError:
ub.session.rollback()
flash(_(u"Found an existing account for this e-mail address."), category="error")
flash(_(u"Found an existing account for this e-mail address or Kobo UserKey."), category="error")
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
translations=translations,
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
......
......@@ -13,3 +13,4 @@ SQLAlchemy>=1.1.0
tornado>=4.1
Wand>=0.4.4
unidecode>=0.04.19
jsonschema>=3.2.0
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment