Make Kobo optional

move jsonschema dependency to optional-requirements.txt
Added version of jsonschema to about section
Added additional column to RemoteAuthToken table
Update configuration of Kobo sync protocol
pull/1138/head
Ozzieisaacs 5 years ago
parent 2798dd5916
commit 79a9ef4859

@ -41,8 +41,13 @@ from cps.shelf import shelf
from cps.admin import admi from cps.admin import admi
from cps.gdrive import gdrive from cps.gdrive import gdrive
from cps.editbooks import editbook from cps.editbooks import editbook
from cps.kobo import kobo
from cps.kobo_auth import kobo_auth try:
from cps.kobo import kobo
from cps.kobo_auth import kobo_auth
kobo_available = True
except ImportError:
kobo_available = False
try: try:
from cps.oauth_bb import oauth from cps.oauth_bb import oauth
@ -61,8 +66,9 @@ def main():
app.register_blueprint(admi) app.register_blueprint(admi)
app.register_blueprint(gdrive) app.register_blueprint(gdrive)
app.register_blueprint(editbook) app.register_blueprint(editbook)
app.register_blueprint(kobo) if kobo_available:
app.register_blueprint(kobo_auth) app.register_blueprint(kobo)
app.register_blueprint(kobo_auth)
if oauth_available: if oauth_available:
app.register_blueprint(oauth) app.register_blueprint(oauth)
success = web_server.start() success = web_server.start()

@ -67,6 +67,7 @@ _VERSIONS = OrderedDict(
Unidecode = unidecode_version, Unidecode = unidecode_version,
Flask_SimpleLDAP = u'installed' if bool(services.ldap) else u'not installed', Flask_SimpleLDAP = u'installed' if bool(services.ldap) else u'not installed',
Goodreads = u'installed' if bool(services.goodreads_support) else u'not installed', Goodreads = u'installed' if bool(services.goodreads_support) else u'not installed',
jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else u'not installed',
) )
_VERSIONS.update(uploader.get_versions()) _VERSIONS.update(uploader.get_versions())

@ -45,7 +45,8 @@ from .web import admin_required, render_title_template, before_request, unconfig
feature_support = { feature_support = {
'ldap': False, # bool(services.ldap), 'ldap': False, # bool(services.ldap),
'goodreads': bool(services.goodreads_support) 'goodreads': bool(services.goodreads_support),
'kobo': bool(services.kobo)
} }
# try: # try:
@ -63,6 +64,7 @@ except ImportError:
oauth_check = {} oauth_check = {}
feature_support['gdrive'] = gdrive_support feature_support['gdrive'] = gdrive_support
admi = Blueprint('admin', __name__) admi = Blueprint('admin', __name__)
log = logger.create() log = logger.create()
@ -568,7 +570,7 @@ def _configuration_update_helper():
# Remote login configuration # Remote login configuration
_config_checkbox("config_remote_login") _config_checkbox("config_remote_login")
if not config.config_remote_login: if not config.config_remote_login:
ub.session.query(ub.RemoteAuthToken).delete() ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.token_type==0).delete()
# Goodreads configuration # Goodreads configuration
_config_checkbox("config_use_goodreads") _config_checkbox("config_use_goodreads")
@ -693,7 +695,8 @@ def new_user():
if not to_save["nickname"] or not to_save["email"] or not to_save["password"]: if not to_save["nickname"] or not to_save["email"] or not to_save["password"]:
flash(_(u"Please fill out all fields!"), category="error") flash(_(u"Please fill out all fields!"), category="error")
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
registered_oauth=oauth_check, title=_(u"Add new user")) registered_oauth=oauth_check, feature_support=feature_support,
title=_(u"Add new user"))
content.password = generate_password_hash(to_save["password"]) content.password = generate_password_hash(to_save["password"])
existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"].lower())\ existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"].lower())\
.first() .first()
@ -704,14 +707,15 @@ def new_user():
if config.config_public_reg and not check_valid_domain(to_save["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") flash(_(u"E-mail is not from valid domain"), category="error")
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
registered_oauth=oauth_check, title=_(u"Add new user")) registered_oauth=oauth_check, feature_support=feature_support,
title=_(u"Add new user"))
else: else:
content.email = to_save["email"] content.email = to_save["email"]
else: else:
flash(_(u"Found an existing account for this e-mail address or nickname."), category="error") flash(_(u"Found an existing account for this e-mail address or nickname."), category="error")
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
languages=languages, title=_(u"Add new user"), page="newuser", languages=languages, title=_(u"Add new user"), page="newuser",
registered_oauth=oauth_check) feature_support=feature_support, registered_oauth=oauth_check)
try: try:
ub.session.add(content) ub.session.add(content)
ub.session.commit() ub.session.commit()
@ -729,7 +733,7 @@ def new_user():
# content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT) # content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT)
return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, return render_title_template("user_edit.html", new_user=1, content=content, translations=translations,
languages=languages, title=_(u"Add new user"), page="newuser", languages=languages, title=_(u"Add new user"), page="newuser",
registered_oauth=oauth_check) feature_support=feature_support, registered_oauth=oauth_check)
@admi.route("/admin/mailsettings") @admi.route("/admin/mailsettings")
@ -850,8 +854,14 @@ def edit_user(user_id):
content.kobo_user_key_hash = kobo_user_key_hash content.kobo_user_key_hash = kobo_user_key_hash
else: else:
flash(_(u"Found an existing account for this Kobo UserKey."), category="error") flash(_(u"Found an existing account for this Kobo UserKey."), category="error")
return render_title_template("user_edit.html", translations=translations, languages=languages, return render_title_template("user_edit.html",
new_user=0, content=content, downloads=downloads, registered_oauth=oauth_check, translations=translations,
languages=languages,
new_user=0,
content=content,
downloads=downloads,
registered_oauth=oauth_check,
feature_support=feature_support,
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
if to_save["email"] and to_save["email"] != content.email: 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()) \ existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \
@ -860,9 +870,15 @@ def edit_user(user_id):
content.email = to_save["email"] content.email = to_save["email"]
else: else:
flash(_(u"Found an existing account for this e-mail address."), category="error") flash(_(u"Found an existing account for this e-mail address."), category="error")
return render_title_template("user_edit.html", translations=translations, languages=languages, return render_title_template("user_edit.html",
translations=translations,
languages=languages,
mail_configured = config.get_mail_server_configured(), mail_configured = config.get_mail_server_configured(),
new_user=0, content=content, downloads=downloads, registered_oauth=oauth_check, feature_support=feature_support,
new_user=0,
content=content,
downloads=downloads,
registered_oauth=oauth_check,
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
if "nickname" in to_save and to_save["nickname"] != content.nickname: if "nickname" in to_save and to_save["nickname"] != content.nickname:
# Query User nickname, if not existing, change # Query User nickname, if not existing, change
@ -877,6 +893,7 @@ def edit_user(user_id):
new_user=0, content=content, new_user=0, content=content,
downloads=downloads, downloads=downloads,
registered_oauth=oauth_check, registered_oauth=oauth_check,
feature_support=feature_support,
title=_(u"Edit User %(nick)s", nick=content.nickname), title=_(u"Edit User %(nick)s", nick=content.nickname),
page="edituser") page="edituser")
@ -888,9 +905,15 @@ def edit_user(user_id):
except IntegrityError: except IntegrityError:
ub.session.rollback() ub.session.rollback()
flash(_(u"An unknown error occured."), category="error") flash(_(u"An unknown error occured."), category="error")
return render_title_template("user_edit.html", translations=translations, languages=languages, new_user=0, return render_title_template("user_edit.html",
content=content, downloads=downloads, registered_oauth=oauth_check, translations=translations,
languages=languages,
new_user=0,
content=content,
downloads=downloads,
registered_oauth=oauth_check,
mail_configured=config.get_mail_server_configured(), mail_configured=config.get_mail_server_configured(),
feature_support=feature_support,
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")

@ -17,10 +17,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import sys import sys
import uuid import uuid
from base64 import b64decode, b64encode
from datetime import datetime from datetime import datetime
from time import gmtime, strftime from time import gmtime, strftime
try: try:
@ -28,14 +26,12 @@ try:
except ImportError: except ImportError:
from urllib.parse import unquote from urllib.parse import unquote
from jsonschema import validate, exceptions
from flask import ( from flask import (
Blueprint, Blueprint,
request, request,
make_response, make_response,
jsonify, jsonify,
json, json,
current_app,
url_for, url_for,
redirect, redirect,
) )
@ -44,7 +40,7 @@ from werkzeug.datastructures import Headers
from sqlalchemy import func from sqlalchemy import func
import requests import requests
from . import config, logger, kobo_auth, db, helper from . import config, logger, kobo_auth, db, helper, services
from .web import download_required from .web import download_required
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB", "EPUB3"]} KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB", "EPUB3"]}
@ -56,19 +52,6 @@ kobo_auth.register_url_value_preprocessor(kobo)
log = logger.create() 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()
def get_store_url_for_current_request(): def get_store_url_for_current_request():
# Programmatically modify the current url to point to the official Kobo store # Programmatically modify the current url to point to the official Kobo store
base, sep, request_path_with_auth_token = request.full_path.rpartition("/kobo/") base, sep, request_path_with_auth_token = request.full_path.rpartition("/kobo/")
@ -110,117 +93,11 @@ def redirect_or_proxy_request():
) )
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 set_kobo_store_header(self, store_headers):
store_headers.set(SyncToken.SYNC_TOKEN_HEADER, self.raw_kobo_store_token)
def merge_from_store_response(self, store_response):
self.raw_kobo_store_token = store_response.headers.get(
SyncToken.SYNC_TOKEN_HEADER, ""
)
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") @kobo.route("/v1/library/sync")
@login_required @login_required
@download_required @download_required
def HandleSyncRequest(): def HandleSyncRequest():
sync_token = SyncToken.from_headers(request.headers) sync_token = services.SyncToken.from_headers(request.headers)
log.info("Kobo library sync request received.") log.info("Kobo library sync request received.")
# TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header # TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header

@ -23,13 +23,13 @@ This module also includes research notes into the auth protocol used by Kobo dev
Log-in: Log-in:
When first booting a Kobo device the user must sign into a Kobo (or affiliate) account. 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 Upon successful sign-in, the user is redirected to
https://auth.kobobooks.com/CrossDomainSignIn?id=<some id> https://auth.kobobooks.com/CrossDomainSignIn?id=<some id>
which serves the following response: 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>. <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. And triggers the insertion of a userKey into the device's User table.
Together, the device's DeviceId and UserKey act as an *irrevocable* authentication 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 token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is
required to authorize the API call. required to authorize the API call.
@ -95,7 +95,7 @@ def load_user_from_kobo_request(request):
user = ( user = (
ub.session.query(ub.User) ub.session.query(ub.User)
.join(ub.RemoteAuthToken) .join(ub.RemoteAuthToken)
.filter(ub.RemoteAuthToken.auth_token == auth_token) .filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1)
.first() .first()
) )
if user is not None: if user is not None:
@ -108,21 +108,23 @@ def load_user_from_kobo_request(request):
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth") kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
@kobo_auth.route("/generate_auth_token") @kobo_auth.route("/generate_auth_token/<int:user_id>")
@login_required @login_required
def generate_auth_token(): def generate_auth_token(user_id):
# Invalidate any prevously generated Kobo Auth token for this user. # Invalidate any prevously generated Kobo Auth token for this user.
ub.session.query(ub.RemoteAuthToken).filter( auth_token = ub.session.query(ub.RemoteAuthToken).filter(
ub.RemoteAuthToken.user_id == current_user.id ub.RemoteAuthToken.user_id == user_id
).delete() ).filter(ub.RemoteAuthToken.token_type==1).first()
auth_token = ub.RemoteAuthToken() if not auth_token:
auth_token.user_id = current_user.id auth_token = ub.RemoteAuthToken()
auth_token.expiration = datetime.max auth_token.user_id = user_id
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8") auth_token.expiration = datetime.max
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
auth_token.token_type = 1
ub.session.add(auth_token) ub.session.add(auth_token)
ub.session.commit() ub.session.commit()
return render_title_template( return render_title_template(
"generate_kobo_auth_url.html", "generate_kobo_auth_url.html",
@ -131,3 +133,13 @@ def generate_auth_token():
"kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True "kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True
), ),
) )
@kobo_auth.route("/deleteauthtoken/<int:user_id>")
@login_required
def delete_auth_token(user_id):
# Invalidate any prevously generated Kobo Auth token for this user.
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
.filter(ub.RemoteAuthToken.token_type==1).delete()
ub.session.commit()
return ""

@ -0,0 +1,148 @@
#!/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 sys
from base64 import b64decode, b64encode
from jsonschema import validate, exceptions, __version__
from datetime import datetime
try:
from urllib import unquote
except ImportError:
from urllib.parse import unquote
from flask import json
from .. import logger as log
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 set_kobo_store_header(self, store_headers):
store_headers.set(SyncToken.SYNC_TOKEN_HEADER, self.raw_kobo_store_token)
def merge_from_store_response(self, store_response):
self.raw_kobo_store_token = store_response.headers.get(
SyncToken.SYNC_TOKEN_HEADER, ""
)
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)

@ -35,4 +35,9 @@ except ImportError as err:
log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err) log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err)
ldap = None ldap = None
try:
from . import SyncToken as SyncToken
kobo = True
except ImportError as err:
log.debug("cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
kobo = None

@ -228,6 +228,41 @@ $(function() {
$(this).find(".modal-body").html("..."); $(this).find(".modal-body").html("...");
}); });
$("#modal_kobo_token")
.on("show.bs.modal", function(e) {
var $modalBody = $(this).find(".modal-body");
// Prevent static assets from loading multiple times
var useCache = function(options) {
options.async = true;
options.cache = true;
};
preFilters.add(useCache);
$.get(e.relatedTarget.href).done(function(content) {
$modalBody.html(content);
preFilters.remove(useCache);
});
})
.on("hidden.bs.modal", function() {
$(this).find(".modal-body").html("...");
$("#config_delete_kobo_token").show();
});
$("#btndeletetoken").click(function() {
//get data-id attribute of the clicked element
var pathname = document.getElementsByTagName("script"), src = pathname[pathname.length-1].src;
var path = src.substring(0,src.lastIndexOf("/"));
// var domainId = $(this).value("domainId");
$.ajax({
method:"get",
url: path + "/../../kobo_auth/deleteauthtoken/" + this.value,
});
$("#modalDeleteToken").modal("hide");
$("#config_delete_kobo_token").hide();
});
$(window).resize(function() { $(window).resize(function() {
$(".discover .row").isotope("layout"); $(".discover .row").isotope("layout");
}); });

@ -1,7 +1,6 @@
{% extends "layout.html" %} {% extends "fragment.html" %}
{% block body %} {% block body %}
<div class="well"> <div class="well">
<h2 style="margin-top: 0">{{_('Generate Kobo Auth URL')}}</h2>
<p> <p>
{{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}</a>. {{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}</a>.
</p> </p>
@ -12,4 +11,4 @@
{{_('Please note that every visit to this current page invalidates any previously generated Authentication url for this user.')}}</a>. {{_('Please note that every visit to this current page invalidates any previously generated Authentication url for this user.')}}</a>.
</p> </p>
</div> </div>
{% endblock %} {% endblock %}

@ -59,6 +59,13 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% if feature_support['kobo'] %}
<label>{{ _('Kobo Sync Token')}}</label>
<div class="form-group col">
<a class="btn btn-default" id="config_create_kobo_token" data-toggle="modal" data-target="#modal_kobo_token" data-remote="false" href="{{ url_for('kobo_auth.generate_auth_token', user_id=content.id) }}">{{_('Create/View')}}</a>
<div class="btn btn-danger" id="config_delete_kobo_token" data-toggle="modal" data-target="#modalDeleteToken" data-remote="false" {% if not content.remote_auth_token.first() %} style="display: none;" {% endif %}>{{_('Delete')}}</div>
</div>
{% endif %}
<div class="col-sm-6"> <div class="col-sm-6">
{% for element in sidebar %} {% for element in sidebar %}
{% if element['config_show'] %} {% if element['config_show'] %}
@ -146,6 +153,35 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="modal fade" id="modal_kobo_token" tabindex="-1" role="dialog" aria-labelledby="kobo_tokenModalLabel">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="kobo_tokenModalLabel">{{_('Generate Kobo Auth URL')}}</h4>
</div>
<div class="modal-body">...</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
</div>
</div>
</div>
</div>
<div id="modalDeleteToken" class="modal fade" role="dialog">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header bg-danger">
</div>
<div class="modal-body text-center">
<p>{{_('Do you really want to delete the Kobo Token?')}}</p>
<button type="button" class="btn btn-danger" id="btndeletetoken" value="{{content.id}}">{{_('Delete')}}</button>
<button type="button" class="btn btn-default" id="btncancel" data-dismiss="modal">{{_('Back')}}</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block modal %} {% block modal %}
{{ restrict_modal() }} {{ restrict_modal() }}

@ -199,6 +199,7 @@ class User(UserBase, Base):
allowed_tags = Column(String, default="") allowed_tags = Column(String, default="")
restricted_column_value = Column(String, default="") restricted_column_value = Column(String, default="")
allowed_column_value = Column(String, default="") allowed_column_value = Column(String, default="")
remote_auth_token = relationship('RemoteAuthToken', backref='user', lazy='dynamic')
if oauth_support: if oauth_support:
@ -333,6 +334,7 @@ class RemoteAuthToken(Base):
user_id = Column(Integer, ForeignKey('user.id')) user_id = Column(Integer, ForeignKey('user.id'))
verified = Column(Boolean, default=False) verified = Column(Boolean, default=False)
expiration = Column(DateTime) expiration = Column(DateTime)
token_type = Column(Integer, default=0)
def __init__(self): def __init__(self):
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8') self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
@ -364,6 +366,15 @@ def migrate_Database(session):
conn.execute("ALTER TABLE registration ADD column 'allow' INTEGER") conn.execute("ALTER TABLE registration ADD column 'allow' INTEGER")
conn.execute("update registration set 'allow' = 1") conn.execute("update registration set 'allow' = 1")
session.commit() session.commit()
try:
session.query(exists().where(RemoteAuthToken.token_type)).scalar()
session.commit()
except exc.OperationalError: # Database is not compatible, some columns are missing
conn = engine.connect()
conn.execute("ALTER TABLE remote_auth_token ADD column 'token_type' INTEGER DEFAULT 0")
conn.execute("update remote_auth_token set 'token_type' = 0")
session.commit()
# Handle table exists, but no content # Handle table exists, but no content
cnt = session.query(Registration).count() cnt = session.query(Registration).count()
if not cnt: if not cnt:

@ -55,7 +55,8 @@ from .redirect import redirect_back
feature_support = { feature_support = {
'ldap': False, # bool(services.ldap), 'ldap': False, # bool(services.ldap),
'goodreads': bool(services.goodreads_support) 'goodreads': bool(services.goodreads_support),
'kobo': bool(services.kobo)
} }
try: try:
@ -1319,6 +1320,7 @@ def profile():
flash(_(u"E-mail is not from valid domain"), category="error") flash(_(u"E-mail is not from valid domain"), category="error")
return render_title_template("user_edit.html", content=current_user, downloads=downloads, return render_title_template("user_edit.html", content=current_user, downloads=downloads,
title=_(u"%(name)s's profile", name=current_user.nickname), page="me", title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
feature_support=feature_support,
registered_oauth=oauth_check, oauth_status=oauth_status) registered_oauth=oauth_check, oauth_status=oauth_status)
if "nickname" in to_save and to_save["nickname"] != current_user.nickname: if "nickname" in to_save and to_save["nickname"] != current_user.nickname:
# Query User nickname, if not existing, change # Query User nickname, if not existing, change
@ -1329,6 +1331,7 @@ def profile():
return render_title_template("user_edit.html", return render_title_template("user_edit.html",
translations=translations, translations=translations,
languages=languages, languages=languages,
feature_support=feature_support,
new_user=0, content=current_user, new_user=0, content=current_user,
downloads=downloads, downloads=downloads,
registered_oauth=oauth_check, registered_oauth=oauth_check,
@ -1360,13 +1363,13 @@ def profile():
flash(_(u"Found an existing account for this e-mail address."), category="error") flash(_(u"Found an existing account for this e-mail address."), category="error")
log.debug(u"Found an existing account for this e-mail address.") log.debug(u"Found an existing account for this e-mail address.")
return render_title_template("user_edit.html", content=current_user, downloads=downloads, return render_title_template("user_edit.html", content=current_user, downloads=downloads,
translations=translations, translations=translations, feature_support=feature_support,
title=_(u"%(name)s's profile", name=current_user.nickname), page="me", title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
registered_oauth=oauth_check, oauth_status=oauth_status) registered_oauth=oauth_check, oauth_status=oauth_status)
flash(_(u"Profile updated"), category="success") flash(_(u"Profile updated"), category="success")
log.debug(u"Profile updated") log.debug(u"Profile updated")
return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages, return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages,
content=current_user, downloads=downloads, content=current_user, downloads=downloads, feature_support=feature_support,
title= _(u"%(name)s's profile", name=current_user.nickname), title= _(u"%(name)s's profile", name=current_user.nickname),
page="me", registered_oauth=oauth_check, oauth_status=oauth_status) page="me", registered_oauth=oauth_check, oauth_status=oauth_status)

@ -32,3 +32,6 @@ rarfile>=2.7
# other # other
natsort>=2.2.0 natsort>=2.2.0
git+https://github.com/OzzieIsaacs/comicapi.git@5346716578b2843f54d522f44d01bc8d25001d24#egg=comicapi git+https://github.com/OzzieIsaacs/comicapi.git@5346716578b2843f54d522f44d01bc8d25001d24#egg=comicapi
#kobo integration
jsonschema>=3.2.0

@ -13,4 +13,3 @@ SQLAlchemy>=1.1.0
tornado>=4.1 tornado>=4.1
Wand>=0.4.4 Wand>=0.4.4
unidecode>=0.04.19 unidecode>=0.04.19
jsonschema>=3.2.0

Loading…
Cancel
Save