Merge branch 'Develop'

# Conflicts:
#	cps/db.py
#	cps/templates/user_edit.html
This commit is contained in:
Ozzieisaacs 2020-02-23 13:15:30 +01:00
commit 146068c936
26 changed files with 4726 additions and 2495 deletions

11
cps.py
View file

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

View file

@ -70,7 +70,7 @@ _VERSIONS = OrderedDict(
Unidecode = unidecode_version,
Flask_SimpleLDAP = u'installed' if bool(services.ldap) 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())

View file

@ -44,7 +44,8 @@ from .web import admin_required, render_title_template, before_request, unconfig
feature_support = {
'ldap': False, # bool(services.ldap),
'goodreads': bool(services.goodreads_support)
'goodreads': bool(services.goodreads_support),
'kobo': bool(services.kobo)
}
# try:
@ -143,7 +144,10 @@ def configuration():
def view_configuration():
readColumn = db.session.query(db.Custom_Columns)\
.filter(and_(db.Custom_Columns.datatype == 'bool',db.Custom_Columns.mark_for_delete == 0)).all()
restrictColumns= db.session.query(db.Custom_Columns)\
.filter(and_(db.Custom_Columns.datatype == 'text',db.Custom_Columns.mark_for_delete == 0)).all()
return render_title_template("config_view_edit.html", conf=config, readColumns=readColumn,
restrictColumns=restrictColumns,
title=_(u"UI Configuration"), page="uiconfig")
@ -159,7 +163,7 @@ def update_view_configuration():
_config_string("config_calibre_web_title")
_config_string("config_columns_to_ignore")
_config_string("config_mature_content_tags")
# _config_string("config_mature_content_tags")
reboot_required |= _config_string("config_title_regex")
_config_int("config_read_column")
@ -167,6 +171,7 @@ def update_view_configuration():
_config_int("config_random_books")
_config_int("config_books_per_page")
_config_int("config_authors_max")
_config_int("config_restricted_column")
if config.config_google_drive_watch_changes_response:
config.config_google_drive_watch_changes_response = json.dumps(config.config_google_drive_watch_changes_response)
@ -175,8 +180,6 @@ def update_view_configuration():
config.config_default_role &= ~constants.ROLE_ANONYMOUS
config.config_default_show = sum(int(k[5:]) for k in to_save if k.startswith('show_'))
if "Show_mature_content" in to_save:
config.config_default_show |= constants.MATURE_CONTENT
if "Show_detail_random" in to_save:
config.config_default_show |= constants.DETAIL_RANDOM
@ -201,7 +204,6 @@ def edit_domain(allow):
# value: 'superuser!' //new value
vals = request.form.to_dict()
answer = ub.session.query(ub.Registration).filter(ub.Registration.id == vals['pk']).first()
# domain_name = request.args.get('domain')
answer.domain = vals['value'].replace('*', '%').replace('?', '_').lower()
ub.session.commit()
return ""
@ -246,6 +248,228 @@ def list_domain(allow):
response.headers["Content-Type"] = "application/json; charset=utf-8"
return response
@admi.route("/ajax/editrestriction/<int:type>", methods=['POST'])
@login_required
@admin_required
def edit_restriction(type):
element = request.form.to_dict()
if element['id'].startswith('a'):
if type == 0: # Tags as template
elementlist = config.list_allowed_tags()
elementlist[int(element['id'][1:])]=element['Element']
config.config_allowed_tags = ','.join(elementlist)
config.save()
if type == 1: # CustomC
elementlist = config.list_allowed_column_values()
elementlist[int(element['id'][1:])]=element['Element']
config.config_allowed_column_value = ','.join(elementlist)
config.save()
if type == 2: # Tags per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id == int(usr_id)).first()
else:
usr = current_user
elementlist = usr.list_allowed_tags()
elementlist[int(element['id'][1:])]=element['Element']
usr.allowed_tags = ','.join(elementlist)
ub.session.commit()
if type == 3: # CColumn per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id == int(usr_id)).first()
else:
usr = current_user
elementlist = usr.list_allowed_column_values()
elementlist[int(element['id'][1:])]=element['Element']
usr.allowed_column_value = ','.join(elementlist)
ub.session.commit()
if element['id'].startswith('d'):
if type == 0: # Tags as template
elementlist = config.list_denied_tags()
elementlist[int(element['id'][1:])]=element['Element']
config.config_denied_tags = ','.join(elementlist)
config.save()
if type == 1: # CustomC
elementlist = config.list_denied_column_values()
elementlist[int(element['id'][1:])]=element['Element']
config.config_denied_column_value = ','.join(elementlist)
config.save()
pass
if type == 2: # Tags per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id == int(usr_id)).first()
else:
usr = current_user
elementlist = usr.list_denied_tags()
elementlist[int(element['id'][1:])]=element['Element']
usr.denied_tags = ','.join(elementlist)
ub.session.commit()
if type == 3: # CColumn per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id == int(usr_id)).first()
else:
usr = current_user
elementlist = usr.list_denied_column_values()
elementlist[int(element['id'][1:])]=element['Element']
usr.denied_column_value = ','.join(elementlist)
ub.session.commit()
return ""
def restriction_addition(element, list_func):
elementlist = list_func()
if elementlist == ['']:
elementlist = []
if not element['add_element'] in elementlist:
elementlist += [element['add_element']]
return ','.join(elementlist)
def restriction_deletion(element, list_func):
elementlist = list_func()
if element['Element'] in elementlist:
elementlist.remove(element['Element'])
return ','.join(elementlist)
@admi.route("/ajax/addrestriction/<int:type>", methods=['POST'])
@login_required
@admin_required
def add_restriction(type):
element = request.form.to_dict()
if type == 0: # Tags as template
if 'submit_allow' in element:
config.config_allowed_tags = restriction_addition(element, config.list_allowed_tags)
config.save()
elif 'submit_deny' in element:
config.config_denied_tags = restriction_addition(element, config.list_denied_tags)
config.save()
if type == 1: # CCustom as template
if 'submit_allow' in element:
config.config_allowed_column_value = restriction_addition(element, config.list_denied_column_values)
config.save()
elif 'submit_deny' in element:
config.config_denied_column_value = restriction_addition(element, config.list_allowed_column_values)
config.save()
if type == 2: # Tags per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id == int(usr_id)).first()
else:
usr = current_user
if 'submit_allow' in element:
usr.allowed_tags = restriction_addition(element, usr.list_allowed_tags)
ub.session.commit()
elif 'submit_deny' in element:
usr.denied_tags = restriction_addition(element, usr.list_denied_tags)
ub.session.commit()
if type == 3: # CustomC per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id == int(usr_id)).first()
else:
usr = current_user
if 'submit_allow' in element:
usr.allowed_column_value = restriction_addition(element, usr.list_allowed_column_values)
ub.session.commit()
elif 'submit_deny' in element:
usr.denied_column_value = restriction_addition(element, usr.list_denied_column_values)
ub.session.commit()
return ""
@admi.route("/ajax/deleterestriction/<int:type>", methods=['POST'])
@login_required
@admin_required
def delete_restriction(type):
element = request.form.to_dict()
if type == 0: # Tags as template
if element['id'].startswith('a'):
config.config_allowed_tags = restriction_deletion(element, config.list_allowed_tags)
config.save()
elif element['id'].startswith('d'):
config.config_denied_tags = restriction_deletion(element, config.list_denied_tags)
config.save()
elif type == 1: # CustomC as template
if element['id'].startswith('a'):
config.config_allowed_column_value = restriction_deletion(element, config.list_allowed_column_values)
config.save()
elif element['id'].startswith('d'):
config.config_denied_column_value = restriction_deletion(element, config.list_denied_column_values)
config.save()
elif type == 2: # Tags per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id == int(usr_id)).first()
else:
usr = current_user
if element['id'].startswith('a'):
usr.allowed_tags = restriction_deletion(element, usr.list_allowed_tags)
ub.session.commit()
elif element['id'].startswith('d'):
usr.denied_tags = restriction_deletion(element, usr.list_denied_tags)
ub.session.commit()
elif type == 3: # Columns per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True: # select current user if admins are editing their own rights
usr = ub.session.query(ub.User).filter(ub.User.id == int(usr_id)).first()
else:
usr = current_user
if element['id'].startswith('a'):
usr.allowed_column_value = restriction_deletion(element, usr.list_allowed_column_values)
ub.session.commit()
elif element['id'].startswith('d'):
usr.denied_column_value = restriction_deletion(element, usr.list_denied_column_values)
ub.session.commit()
return ""
#@admi.route("/ajax/listrestriction/<int:type>/<int:user_id>", defaults={'user_id': '0'})
@admi.route("/ajax/listrestriction/<int:type>")
@login_required
@admin_required
def list_restriction(type):
if type == 0: # Tags as template
restrict = [{'Element': x, 'type':_('deny'), 'id': 'd'+str(i) }
for i,x in enumerate(config.list_denied_tags()) if x != '' ]
allow = [{'Element': x, 'type':_('allow'), 'id': 'a'+str(i) }
for i,x in enumerate(config.list_allowed_tags()) if x != '']
json_dumps = restrict + allow
elif type == 1: # CustomC as template
restrict = [{'Element': x, 'type':_('deny'), 'id': 'd'+str(i) }
for i,x in enumerate(config.list_denied_column_values()) if x != '' ]
allow = [{'Element': x, 'type':_('allow'), 'id': 'a'+str(i) }
for i,x in enumerate(config.list_allowed_column_values()) if x != '']
json_dumps = restrict + allow
elif type == 2: # Tags per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id == usr_id).first()
else:
usr = current_user
restrict = [{'Element': x, 'type':_('deny'), 'id': 'd'+str(i) }
for i,x in enumerate(usr.list_denied_tags()) if x != '' ]
allow = [{'Element': x, 'type':_('allow'), 'id': 'a'+str(i) }
for i,x in enumerate(usr.list_allowed_tags()) if x != '']
json_dumps = restrict + allow
elif type == 3: # CustomC per user
usr_id = os.path.split(request.referrer)[-1]
if usr_id.isdigit() == True:
usr = ub.session.query(ub.User).filter(ub.User.id==usr_id).first()
else:
usr = current_user
restrict = [{'Element': x, 'type':_('deny'), 'id': 'd'+str(i) }
for i,x in enumerate(usr.list_denied_column_values()) if x != '' ]
allow = [{'Element': x, 'type':_('allow'), 'id': 'a'+str(i) }
for i,x in enumerate(usr.list_allowed_column_values()) if x != '']
json_dumps = restrict + allow
else:
json_dumps=""
js = json.dumps(json_dumps)
response = make_response(js.replace("'", '"'))
response.headers["Content-Type"] = "application/json; charset=utf-8"
return response
@admi.route("/config", methods=["GET", "POST"])
@unconfigured
@ -261,7 +485,6 @@ def _configuration_update_helper():
db_change = False
to_save = request.form.to_dict()
# _config_dict = lambda x: config.set_from_dictionary(to_save, x, lambda y: y['id'])
_config_string = lambda x: config.set_from_dictionary(to_save, x, lambda y: y.strip() if y else y)
_config_int = lambda x: config.set_from_dictionary(to_save, x, int)
_config_checkbox = lambda x: config.set_from_dictionary(to_save, x, lambda y: y == "on", False)
@ -304,6 +527,9 @@ def _configuration_update_helper():
_config_checkbox_int("config_uploading")
_config_checkbox_int("config_anonbrowse")
_config_checkbox_int("config_public_reg")
reboot_required |= _config_checkbox_int("config_kobo_sync")
_config_checkbox_int("config_kobo_proxy")
_config_int("config_ebookconverter")
_config_string("config_calibre")
@ -338,7 +564,7 @@ def _configuration_update_helper():
# Remote login configuration
_config_checkbox("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
_config_checkbox("config_use_goodreads")
@ -448,10 +674,11 @@ def new_user():
content = ub.User()
languages = speaking_language()
translations = [LC('en')] + babel.list_translations()
kobo_support = feature_support['kobo'] and config.config_kobo_sync
if request.method == "POST":
to_save = request.form.to_dict()
content.default_language = to_save["default_language"]
content.mature_content = "Show_mature_content" in to_save
# content.mature_content = "Show_mature_content" in to_save
content.locale = to_save.get("locale", content.locale)
content.sidebar_view = sum(int(key[5:]) for key in to_save if key.startswith('show_'))
@ -463,7 +690,8 @@ def new_user():
if not to_save["nickname"] or not to_save["email"] or not to_save["password"]:
flash(_(u"Please fill out all fields!"), category="error")
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, kobo_support=kobo_support,
title=_(u"Add new user"))
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())\
.first()
@ -474,15 +702,20 @@ def new_user():
if config.config_public_reg and not check_valid_domain(to_save["email"]):
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,
registered_oauth=oauth_check, title=_(u"Add new user"))
registered_oauth=oauth_check, kobo_support=kobo_support,
title=_(u"Add new user"))
else:
content.email = to_save["email"]
else:
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,
languages=languages, title=_(u"Add new user"), page="newuser",
registered_oauth=oauth_check)
kobo_support=kobo_support, registered_oauth=oauth_check)
try:
content.allowed_tags = config.config_allowed_tags
content.denied_tags = config.config_denied_tags
content.allowed_column_value = config.config_allowed_column_value
content.denied_column_value = config.config_denied_column_value
ub.session.add(content)
ub.session.commit()
flash(_(u"User '%(user)s' created", user=content.nickname), category="success")
@ -493,10 +726,9 @@ def new_user():
else:
content.role = config.config_default_role
content.sidebar_view = config.config_default_show
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,
languages=languages, title=_(u"Add new user"), page="newuser",
registered_oauth=oauth_check)
kobo_support=kobo_support, registered_oauth=oauth_check)
@admi.route("/admin/mailsettings")
@ -551,6 +783,7 @@ def edit_user(user_id):
downloads = list()
languages = speaking_language()
translations = babel.list_translations() + [LC('en')]
kobo_support = feature_support['kobo'] and config.config_kobo_sync
for book in content.downloads:
downloadbook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
if downloadbook:
@ -596,8 +829,6 @@ def edit_user(user_id):
else:
content.sidebar_view &= ~constants.DETAIL_RANDOM
content.mature_content = "Show_mature_content" in to_save
if "default_language" in to_save:
content.default_language = to_save["default_language"]
if "locale" in to_save and to_save["locale"]:
@ -609,9 +840,15 @@ def edit_user(user_id):
content.email = to_save["email"]
else:
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(),
new_user=0, content=content, downloads=downloads, registered_oauth=oauth_check,
kobo_support=kobo_support,
new_user=0,
content=content,
downloads=downloads,
registered_oauth=oauth_check,
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
if "nickname" in to_save and to_save["nickname"] != content.nickname:
# Query User nickname, if not existing, change
@ -626,6 +863,7 @@ def edit_user(user_id):
new_user=0, content=content,
downloads=downloads,
registered_oauth=oauth_check,
kobo_support=kobo_support,
title=_(u"Edit User %(nick)s", nick=content.nickname),
page="edituser")
@ -637,9 +875,15 @@ def edit_user(user_id):
except IntegrityError:
ub.session.rollback()
flash(_(u"An unknown error occured."), category="error")
return render_title_template("user_edit.html", translations=translations, languages=languages, new_user=0,
content=content, downloads=downloads, registered_oauth=oauth_check,
return render_title_template("user_edit.html",
translations=translations,
languages=languages,
new_user=0,
content=content,
downloads=downloads,
registered_oauth=oauth_check,
mail_configured=config.get_mail_server_configured(),
kobo_support=kobo_support,
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")

View file

@ -25,7 +25,7 @@ import sys
from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean
from sqlalchemy.ext.declarative import declarative_base
from . import constants, cli, logger
from . import constants, cli, logger, ub
log = logger.create()
@ -68,12 +68,18 @@ class _Settings(_Base):
config_anonbrowse = Column(SmallInteger, default=0)
config_public_reg = Column(SmallInteger, default=0)
config_remote_login = Column(Boolean, default=False)
config_kobo_sync = Column(Boolean, default=False)
config_default_role = Column(SmallInteger, default=0)
config_default_show = Column(SmallInteger, default=6143)
config_columns_to_ignore = Column(String)
config_denied_tags = Column(String, default="")
config_allowed_tags = Column(String, default="")
config_restricted_column = Column(SmallInteger, default=0)
config_denied_column_value = Column(String, default="")
config_allowed_column_value = Column(String, default="")
config_use_google_drive = Column(Boolean, default=False)
config_google_drive_folder = Column(String)
config_google_drive_watch_changes_response = Column(String)
@ -84,7 +90,8 @@ class _Settings(_Base):
config_login_type = Column(Integer, default=0)
# config_oauth_provider = Column(Integer)
config_kobo_proxy = Column(Boolean, default=False)
config_ldap_provider_url = Column(String, default='localhost')
config_ldap_port = Column(SmallInteger, default=389)
@ -179,11 +186,20 @@ class _ConfigSQL(object):
def show_detail_random(self):
return self.show_element_new_user(constants.DETAIL_RANDOM)
def show_mature_content(self):
return self.show_element_new_user(constants.MATURE_CONTENT)
def list_denied_tags(self):
mct = self.config_denied_tags.split(",")
return [t.strip() for t in mct]
def mature_content_tags(self):
mct = self.config_mature_content_tags.split(",")
def list_allowed_tags(self):
mct = self.config_allowed_tags.split(",")
return [t.strip() for t in mct]
def list_denied_column_values(self):
mct = self.config_denied_column_value.split(",")
return [t.strip() for t in mct]
def list_allowed_column_values(self):
mct = self.config_allowed_column_value.split(",")
return [t.strip() for t in mct]
def get_log_level(self):
@ -323,5 +339,12 @@ def load_configuration(session):
if not session.query(_Settings).count():
session.add(_Settings())
session.commit()
return _ConfigSQL(session)
conf = _ConfigSQL(session)
# Migrate from global restrictions to user based restrictions
if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
conf.config_denied_tags = conf.config_mature_content_tags
conf.save()
session.query(ub.User).filter(ub.User.mature_content != True). \
update({"restricted_tags": conf.config_mature_content_tags}, synchronize_session=False)
session.commit()
return conf

View file

@ -25,7 +25,7 @@ import ast
from sqlalchemy import create_engine
from sqlalchemy import Table, Column, ForeignKey
from sqlalchemy import String, Integer, Boolean, Float
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
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)

View file

@ -448,32 +448,46 @@ def delete_book(book, calibrepath, book_format):
return delete_book_file(book, calibrepath, book_format)
def get_book_cover(book_id):
book = db.session.query(db.Books).filter(db.Books.id == book_id).first()
if book.has_cover:
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).filter(common_filters()).first()
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
@ -674,20 +688,40 @@ def common_filters():
lang_filter = db.Books.languages.any(db.Languages.lang_code == current_user.filter_language())
else:
lang_filter = true()
content_rating_filter = false() if current_user.mature_content else \
db.Books.tags.any(db.Tags.name.in_(config.mature_content_tags()))
return and_(lang_filter, ~content_rating_filter)
negtags_list = current_user.list_denied_tags()
postags_list = current_user.list_allowed_tags()
neg_content_tags_filter = false() if negtags_list == [''] else db.Books.tags.any(db.Tags.name.in_(negtags_list))
pos_content_tags_filter = true() if postags_list == [''] else db.Books.tags.any(db.Tags.name.in_(postags_list))
if config.config_restricted_column:
pos_cc_list = current_user.allowed_column_value.split(',')
pos_content_cc_filter = true() if pos_cc_list == [''] else \
getattr(db.Books, 'custom_column_' + str(config.config_restricted_column)).\
any(db.cc_classes[config.config_restricted_column].value.in_(pos_cc_list))
neg_cc_list = current_user.denied_column_value.split(',')
neg_content_cc_filter = false() if neg_cc_list == [''] else \
getattr(db.Books, 'custom_column_' + str(config.config_restricted_column)).\
any(db.cc_classes[config.config_restricted_column].value.in_(neg_cc_list))
else:
pos_content_cc_filter = true()
neg_content_cc_filter = false()
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
pos_content_cc_filter, ~neg_content_cc_filter)
def tags_filters():
return ~(false() if current_user.mature_content else \
db.Tags.name.in_(config.mature_content_tags()))
# return db.session.query(db.Tags).filter(~content_rating_filter).order_by(db.Tags.name).all()
negtags_list = current_user.list_denied_tags()
postags_list = current_user.list_allowed_tags()
neg_content_tags_filter = false() if negtags_list == [''] else db.Tags.name.in_(negtags_list)
pos_content_tags_filter = true() if postags_list == [''] else db.Tags.name.in_(postags_list)
return and_(pos_content_tags_filter, ~neg_content_tags_filter)
# return ~(false()) if postags_list == [''] else db.Tags.in_(postags_list)
# Creates for all stored languages a translated speaking name in the array for the UI
def speaking_language(languages=None):
if not languages:
languages = db.session.query(db.Languages).all()
languages = db.session.query(db.Languages).join(db.books_languages_link).join(db.Books).filter(common_filters())\
.group_by(text('books_languages_link.lang_code')).all()
for lang in languages:
try:
cur_l = LC.parse(lang.lang_code)
@ -774,7 +808,7 @@ def get_cc_columns():
cc = []
for col in tmpcc:
r = re.compile(config.config_columns_to_ignore)
if r.match(col.label):
if not r.match(col.name):
cc.append(col)
else:
cc = tmpcc

622
cps/kobo.py Normal file
View file

@ -0,0 +1,622 @@
#!/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
import base64
import os
import uuid
from time import gmtime, strftime
try:
from urllib import unquote
except ImportError:
from urllib.parse import unquote
from flask import (
Blueprint,
request,
make_response,
jsonify,
current_app,
url_for,
redirect,
abort
)
from flask_login import login_required
from werkzeug.datastructures import Headers
from sqlalchemy import func
import requests
from . import config, logger, kobo_auth, db, helper
from .services import SyncToken as SyncToken
from .web import download_required
from .kobo_auth import requires_kobo_auth
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
kobo = Blueprint("kobo", __name__, url_prefix="/kobo/<auth_token>")
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
kobo_auth.register_url_value_preprocessor(kobo)
log = logger.create()
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",
]
def get_kobo_activated():
return config.config_kobo_sync
def make_request_to_kobo_store(sync_token=None):
outgoing_headers = Headers(request.headers)
outgoing_headers.remove("Host")
if sync_token:
sync_token.set_kobo_store_header(outgoing_headers)
store_response = requests.request(
method=request.method,
url=get_store_url_for_current_request(),
headers=outgoing_headers,
data=request.get_data(),
allow_redirects=False,
timeout=(2, 10)
)
return store_response
def redirect_or_proxy_request():
if config.config_kobo_proxy:
if request.method == "GET":
return redirect(get_store_url_for_current_request(), 307)
if request.method == "DELETE":
log.info('Delete Book')
return make_response(jsonify({}))
else:
# The Kobo device turns other request types into GET requests on redirects, so we instead proxy to the Kobo store ourselves.
store_response = make_request_to_kobo_store()
response_headers = store_response.headers
for header_key in CONNECTION_SPECIFIC_HEADERS:
response_headers.pop(header_key, default=None)
return make_response(
store_response.content, store_response.status_code, response_headers.items()
)
else:
return make_response(jsonify({}))
@kobo.route("/v1/library/sync")
@requires_kobo_auth
@download_required
def HandleSyncRequest():
sync_token = SyncToken.SyncToken.from_headers(request.headers)
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')
# 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 = []
# 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)
# 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(func.datetime(db.Books.last_modified) > sync_token.books_last_modified)
.filter(db.Data.format.in_(KOBO_FORMATS))
.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_created)
sync_token.books_last_created = new_books_last_created
sync_token.books_last_modified = new_books_last_modified
if config.config_kobo_proxy:
return generate_sync_response(request, sync_token, entitlements)
return make_response(jsonify(entitlements))
# Missing feature: Detect server-side book deletions.
def generate_sync_response(request, sync_token, entitlements):
extra_headers = {}
if config.config_kobo_proxy:
# Merge in sync results from the official Kobo store.
try:
store_response = make_request_to_kobo_store(sync_token)
store_entitlements = store_response.json()
entitlements += store_entitlements
sync_token.merge_from_store_response(store_response)
extra_headers["x-kobo-sync"] = store_response.headers.get("x-kobo-sync")
extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode")
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
except Exception as e:
log.error("Failed to receive or parse response from Kobo's sync endpoint: " + str(e))
sync_token.to_headers(extra_headers)
response = make_response(jsonify(entitlements), extra_headers)
return response
@kobo.route("/v1/library/<book_uuid>/metadata")
@requires_kobo_auth
@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')
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 redirect_or_proxy_request()
metadata = get_metadata(book)
return jsonify([metadata])
def get_download_url_for_book(book, book_format):
if not current_app.wsgi_app.is_proxied:
return "{url_scheme}://{url_base}:{url_port}/download/{book_id}/{book_format}".format(
url_scheme=request.environ['wsgi.url_scheme'],
url_base=request.environ['SERVER_NAME'],
url_port=config.config_port,
book_id=book.id,
book_format=book_format.lower()
)
else:
return url_for(
"web.download_link",
book_id=book.id,
book_format=book_format.lower(),
_external=True,
)
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):
download_urls = []
for book_data in book.data:
if book_data.format not in KOBO_FORMATS:
continue
for kobo_format in KOBO_FORMATS[book_data.format]:
# log.debug('Id: %s, Format: %s' % (book.id, kobo_format))
download_urls.append(
{
"Format": kobo_format,
"Size": book_data.uncompressed_size,
"Url": get_download_url_for_book(book, book_data.format),
# The Kobo forma accepts platforms: (Generic, Android)
"Platform": "Generic",
# "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": {},
"PublicationDate": book.pubdate,
"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("/<book_uuid>/image.jpg")
@requires_kobo_auth
def HandleCoverImageRequest(book_uuid):
log.debug("Cover request received for book %s" % book_uuid)
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:
return redirect(get_store_url_for_current_request(), 307)
else:
abort(404)
return book_cover
@kobo.route("")
def TopLevelEndpoint():
return make_response(jsonify({}))
# TODO: Implement the following routes
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
@kobo.route("/v1/library/<book_uuid>/state", methods=["PUT"])
@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"])
def HandleUnimplementedRequest(dummy=None, book_uuid=None, shelf_name=None, tag_id=None):
log.debug("Alternative Request received:")
return redirect_or_proxy_request()
# 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()
@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"])
def HandleProductsRequest(dummy=None):
log.debug("Unimplemented Products Request received: %s", request.base_url)
return redirect_or_proxy_request()
@kobo.app_errorhandler(404)
def handle_404(err):
# This handler acts as a catch-all for endpoints that we don't have an interest in
# implementing (e.g: v1/analytics/gettests, v1/user/recommendations, etc)
log.debug("Unknown Request received: %s", request.base_url)
return redirect_or_proxy_request()
def make_calibre_web_auth_response():
# As described in kobo_auth.py, CalibreWeb doesn't make use practical use of this auth/device API call for
# authentation (nor for authorization). We return a dummy response just to keep the device happy.
content = request.get_json()
AccessToken = base64.b64encode(os.urandom(24)).decode('utf-8')
RefreshToken = base64.b64encode(os.urandom(24)).decode('utf-8')
return make_response(
jsonify(
{
"AccessToken": AccessToken,
"RefreshToken": RefreshToken,
"TokenType": "Bearer",
"TrackingId": str(uuid.uuid4()),
"UserKey": content['UserKey'],
}
)
)
@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
@kobo.route("/v1/initialization")
@requires_kobo_auth
def HandleInitRequest():
log.info('Init')
if not current_app.wsgi_app.is_proxied:
log.debug('Kobo: Received unproxied request, changed request port to server port')
if request.environ['SERVER_NAME'] != '::':
calibre_web_url = "{url_scheme}://{url_base}:{url_port}".format(
url_scheme=request.environ['wsgi.url_scheme'],
url_base=request.environ['SERVER_NAME'],
url_port=config.config_port
)
else:
log.debug('Kobo: Received unproxied request, on IPV6 host')
calibre_web_url = url_for("web.index", _external=True).strip("/")
else:
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)
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}")),
"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",
}

163
cps/kobo_auth.py Normal file
View file

@ -0,0 +1,163 @@
#!/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.
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:
We pretty much ignore all of the above. To authenticate the user, we generate a random
and unique token that they append to the CalibreWeb Url when setting up the api_store
setting on the device.
Thus, every request from the device to the api_store will hit CalibreWeb with the
auth_token in the url (e.g: https://mylibrary.com/<auth_token>/v1/library/sync).
In addition, once authenticated we also set the login cookie on the response that will
be sent back for the duration of the session to authorize subsequent API calls (in
particular calls to non-Kobo specific endpoints such as the CalibreWeb book download).
"""
from binascii import hexlify
from datetime import datetime
from os import urandom
import os
from flask import g, Blueprint, url_for, abort, request
from flask_login import login_user, login_required
from flask_babel import gettext as _
from . import logger, ub, lm
from .web import render_title_template
try:
from functools import wraps
except ImportError:
pass # We're not using Python 3
log = logger.create()
def register_url_value_preprocessor(kobo):
@kobo.url_value_preprocessor
def pop_auth_token(endpoint, values):
g.auth_token = values.pop("auth_token")
def disable_failed_auth_redirect_for_blueprint(bp):
lm.blueprint_login_views[bp.name] = None
def get_auth_token():
if "auth_token" in g:
return g.get("auth_token")
else:
return None
def requires_kobo_auth(f):
@wraps(f)
def inner(*args, **kwargs):
auth_token = get_auth_token()
if auth_token is not None:
user = (
ub.session.query(ub.User)
.join(ub.RemoteAuthToken)
.filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1)
.first()
)
if user is not None:
login_user(user)
return f(*args, **kwargs)
log.debug("Received Kobo request without a recognizable auth token.")
return abort(401)
return inner
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
@kobo_auth.route("/generate_auth_token/<int:user_id>")
@login_required
def generate_auth_token(user_id):
host = ':'.join(request.host.rsplit(':')[0:-1])
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f'):
warning = _('PLease access calibre-web from non localhost to get valid api_endpoint for kobo device')
return render_title_template(
"generate_kobo_auth_url.html",
title=_(u"Kobo Set-up"),
warning = warning
)
else:
# Invalidate any prevously generated Kobo Auth token for this user.
auth_token = ub.session.query(ub.RemoteAuthToken).filter(
ub.RemoteAuthToken.user_id == user_id
).filter(ub.RemoteAuthToken.token_type==1).first()
if not auth_token:
auth_token = ub.RemoteAuthToken()
auth_token.user_id = user_id
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.commit()
return render_title_template(
"generate_kobo_auth_url.html",
title=_(u"Kobo Set-up"),
kobo_auth_url=url_for(
"kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True
),
warning = False
)
@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 ""

View file

@ -59,10 +59,13 @@ class ReverseProxied(object):
def __init__(self, application):
self.app = application
self.proxied = False
def __call__(self, environ, start_response):
self.proxied = False
script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
if script_name:
self.proxied = True
environ['SCRIPT_NAME'] = script_name
path_info = environ.get('PATH_INFO', '')
if path_info and path_info.startswith(script_name):
@ -75,3 +78,7 @@ class ReverseProxied(object):
if servr:
environ['HTTP_HOST'] = servr
return self.app(environ, start_response)
@property
def is_proxied(self):
return self.proxied

View file

@ -55,6 +55,7 @@ class WebServer(object):
def __init__(self):
signal.signal(signal.SIGINT, self._killServer)
signal.signal(signal.SIGTERM, self._killServer)
signal.signal(signal.SIGQUIT, self._killServer)
self.wsgiserver = None
self.access_logger = None

148
cps/services/SyncToken.py Normal file
View file

@ -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)

View file

@ -35,4 +35,10 @@ except ImportError as err:
log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err)
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
SyncToken = None

View file

@ -293,9 +293,11 @@ def show_shelf(shelf_type, shelf_id):
if cur_book:
result.append(cur_book)
else:
log.info('Not existing book %s in %s deleted', book.book_id, shelf)
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete()
ub.session.commit()
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
if not cur_book:
log.info('Not existing book %s in %s deleted', book.book_id, shelf)
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete()
ub.session.commit()
return render_title_template(page, entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, page="shelf")
else:
@ -329,9 +331,18 @@ def order_shelf(shelf_id):
.order_by(ub.BookShelf.order.asc()).all()
for book in books_in_shelf2:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
result.append(cur_book)
#books_list = [ b.book_id for b in books_in_shelf2]
#result = db.session.query(db.Books).filter(db.Books.id.in_(books_list)).filter(common_filters()).all()
if cur_book:
result.append({'title':cur_book.title,
'id':cur_book.id,
'author':cur_book.authors,
'series':cur_book.series,
'series_index':cur_book.series_index})
else:
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
result.append({'title':_('Hidden Book'),
'id':cur_book.id,
'author':[],
'series':[]})
return render_title_template('shelf_order.html', entries=result,
title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
shelf=shelf, page="shelforder")

View file

@ -26,7 +26,7 @@ html.http-error {
body{background:#f2f2f2}body h2{font-weight:normal;color:#444}
body { margin-bottom: 40px;}
a{color: #45b29d}a:hover{color: #444;}
a{color: #45b29d} /*a:hover{color: #444;}*/
.navigation .nav-head{text-transform:uppercase;color:#999;margin:20px 0}.navigation .nav-head:nth-child(1n+2){border-top:1px solid #ccc;padding-top:20px}
.navigation li a{color:#444;text-decoration:none;display:block;padding:10px}.navigation li a:hover{background:rgba(153,153,153,0.4);border-radius:5px}
.navigation li a span{margin-right:10px}
@ -82,6 +82,12 @@ span.glyphicon.glyphicon-tags {padding-right: 5px;color: #999;vertical-align: te
.spinner {margin:0 41%;}
.spinner2 {margin:0 41%;}
table .bg-dark-danger {background-color: #d9534f; color: #fff;}
table .bg-dark-danger a {color: #fff;}
table .bg-dark-danger:hover {background-color: #c9302c;}
table .bg-primary:hover {background-color: #1C5484;}
table .bg-primary a {color: #fff;}
.block-label {display: block;}
.fake-input {position: absolute; pointer-events: none; top: 0;}

View file

@ -228,6 +228,41 @@ $(function() {
$(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() {
$(".discover .row").isotope("layout");
});

View file

@ -93,6 +93,116 @@ $(function() {
var domainId = $(e.relatedTarget).data("domain-id");
$(e.currentTarget).find("#btndeletedomain").data("domainId", domainId);
});
$('#restrictModal').on('hidden.bs.modal', function () {
// Destroy table and remove hooks for buttons
$("#restrict-elements-table").unbind();
$('#restrict-elements-table').bootstrapTable('destroy');
$("[id^=submit_]").unbind();
$('#h1').addClass('hidden');
$('#h2').addClass('hidden');
$('#h3').addClass('hidden');
$('#h4').addClass('hidden');
});
function startTable(type){
var pathname = document.getElementsByTagName("script"), src = pathname[pathname.length-1].src;
var path = src.substring(0,src.lastIndexOf("/"));
$("#restrict-elements-table").bootstrapTable({
formatNoMatches: function () {
return "";
},
url: path + "/../../ajax/listrestriction/" + type,
rowStyle: function(row, index) {
console.log('Reihe :' + row + ' Index :'+ index);
if (row.id.charAt(0) == 'a') {
return {classes: 'bg-primary'}
}
else {
return {classes: 'bg-dark-danger'}
}
},
onClickCell: function (field, value, row, $element) {
if(field == 3){
console.log("element")
$.ajax ({
type: 'Post',
data: 'id=' + row.id + '&type=' + row.type + "&Element=" + row.Element,
url: path + "/../../ajax/deleterestriction/" + type,
async: true,
timeout: 900,
success:function(data) {
$.ajax({
method:"get",
url: path + "/../../ajax/listrestriction/"+type,
async: true,
timeout: 900,
success:function(data) {
$("#restrict-elements-table").bootstrapTable("load", data);
}
});
}
});
}
},
striped: false
});
$("#restrict-elements-table").removeClass('table-hover');
$("#restrict-elements-table").on('editable-save.bs.table', function (e, field, row, old, $el) {
console.log("Hallo");
$.ajax({
url: path + "/../../ajax/editrestriction/"+type,
type: 'Post',
data: row //$(this).closest("form").serialize() + "&" + $(this)[0].name + "=",
});
});
$("[id^=submit_]").click(function(event) {
// event.stopPropagation();
// event.preventDefault();
$(this)[0].blur();
console.log($(this)[0].name);
$.ajax({
url: path + "/../../ajax/addrestriction/"+type,
type: 'Post',
data: $(this).closest("form").serialize() + "&" + $(this)[0].name + "=",
success: function () {
$.ajax ({
method:"get",
url: path + "/../../ajax/listrestriction/"+type,
async: true,
timeout: 900,
success:function(data) {
$("#restrict-elements-table").bootstrapTable("load", data);
}
});
}
});
return;
});
}
$('#get_column_values').on('click',function()
{
startTable(1);
$('#h2').removeClass('hidden');
});
$('#get_tags').on('click',function()
{
startTable(0);
$('#h1').removeClass('hidden');
});
$('#get_user_column_values').on('click',function()
{
startTable(3);
$('#h4').removeClass('hidden');
});
$('#get_user_tags').on('click',function()
{
startTable(2);
$(this)[0].blur();
$('#h3').removeClass('hidden');
});
});
/* Function for deleting domain restrictions */
@ -104,3 +214,12 @@ function TableActions (value, row, index) {
"</a>"
].join("");
}
/* Function for deleting domain restrictions */
function RestrictionActions (value, row, index) {
return [
"<div class=\"danger remove\" data-restriction-id=\"" + row.id + "\" title=\"Remove\">",
"<i class=\"glyphicon glyphicon-trash\"></i>",
"</div>"
].join("");
}

View file

@ -1,6 +1,6 @@
{% extends "layout.html" %}
{% block body %}
<div class="discover">
<div class="discover" xmlns:text-indent="http://www.w3.org/1999/xhtml">
<h2>{{title}}</h2>
<form role="form" method="POST" autocomplete="off">
<div class="panel-group">
@ -71,7 +71,7 @@
</div>
</div>
</div>
{% if show_back_button %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
@ -169,6 +169,18 @@
<input type="checkbox" id="config_remote_login" name="config_remote_login" {% if config.config_remote_login %}checked{% endif %}>
<label for="config_remote_login">{{_('Enable remote login ("magic link")')}}</label>
</div>
{% if feature_support['kobo'] %}
<div class="form-group">
<input type="checkbox" id="config_kobo_sync" name="config_kobo_sync" data-control="kobo-settings" {% if config.config_kobo_sync %}checked{% endif %}>
<label for="config_kobo_sync">{{_('Enable Kobo sync')}}</label>
</div>
<div data-related="kobo-settings">
<div class="form-group" style="text-indent:10px;">
<input type="checkbox" id="config_kobo_proxy" name="config_kobo_proxy" {% if config.config_kobo_proxy %}checked{% endif %}>
<label for="config_kobo_proxy">{{_('Proxy unknown requests to Kobo Store')}}</label>
</div>
</div>
{% endif %}
{% if feature_support['goodreads'] %}
<div class="form-group">
<input type="checkbox" id="config_use_goodreads" name="config_use_goodreads" data-control="goodreads-settings" {% if config.config_use_goodreads %}checked{% endif %}>
@ -322,11 +334,14 @@
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-sm-12">
{% if not show_login_button %}
<button type="submit" name="submit" class="btn btn-default">{{_('Submit')}}</button>
{% endif %}
{% if show_back_button %}
<a href="{{ url_for('admin.admin') }}" class="btn btn-default">{{_('Back')}}</a>
{% endif %}

View file

@ -1,4 +1,8 @@
{% extends "layout.html" %}
{% block header %}
<link href="{{ url_for('static', filename='css/libs/bootstrap-table.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/libs/bootstrap-editable.css') }}" rel="stylesheet">
{% endblock %}
{% block body %}
<div class="discover">
<h2>{{title}}</h2>
@ -51,16 +55,19 @@
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="config_restricted_column">{{_('View restriction based on Calibre column')}}</label>
<select name="config_restricted_column" id="config_restricted_column" class="form-control">
<option value="0" {% if conf.config_restricted_column == 0 %}selected{% endif %}>{{ _('None') }}</option>
{% for restrictColumn in restrictColumns %}
<option value="{{ restrictColumn.id }}" {% if restrictColumn.id == conf.config_restricted_column %}selected{% endif %}>{{ restrictColumn.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="config_title_regex">{{_('Regular expression for title sorting')}}</label>
<input type="text" class="form-control" name="config_title_regex" id="config_title_regex" value="{% if conf.config_title_regex != None %}{{ conf.config_title_regex }}{% endif %}" autocomplete="off">
</div>
<div class="form-group">
<label for="config_mature_content_tags">{{_('Tags for Mature Content')}}</label>
<input type="text" class="form-control" name="config_mature_content_tags" id="config_mature_content_tags"
value="{% if conf.config_mature_content_tags != None%}{{ conf.config_mature_content_tags }}{% endif %}"
autocomplete="off"
>
</div>
</div>
</div>
@ -134,14 +141,11 @@
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if conf.show_detail_random() %}checked{% endif %}>
<label for="Show_detail_random">{{_('Show random books in detail view')}}</label>
</div>
<div class="form-group">
<input type="checkbox" name="Show_mature_content" id="Show_mature_content" {% if conf.show_mature_content() %}checked{% endif %}>
<label for="Show_mature_content">{{_('Show mature content')}}</label>
</div>
<a href="#" id="get_tags" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/denied Tags')}}</a>
<a href="#" id="get_column_values" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/denied custom column values')}}</a>
</div>
</div>
</div>
</div>
<div class="col-sm-12">
<button type="submit" name="submit" class="btn btn-default">{{_('Submit')}}</button>
<a href="{{ url_for('admin.admin') }}" class="btn btn-default">{{_('Back')}}</a>
@ -149,6 +153,9 @@
</form>
</div>
{% endblock %}
{% block modal %}
{{ restrict_modal() }}
{% endblock %}
{% block js %}
<script type="text/javascript">
$('.collapse').on('shown.bs.collapse', function(){
@ -157,4 +164,8 @@
$(this).parent().find(".glyphicon-minus").removeClass("glyphicon-minus").addClass("glyphicon-plus");
});
</script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/table.js') }}"></script>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "fragment.html" %}
{% block body %}
<div class="well">
<p>
{{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}</a>
</p>
<p>
{% if not warning %}{{_('api_endpoint=')}}{{kobo_auth_url}}{% else %}{{warning}}{% endif %}</a>
</p>
<p>
{{_('Please note that every visit to this current page invalidates any previously generated Authentication url for this user.')}}</a>
</p>
</div>
{% endblock %}

View file

@ -1,3 +1,4 @@
{% from 'modal_restriction.html' import restrict_modal %}
<!DOCTYPE html>
<html lang="{{ g.user.locale }}">
<head>
@ -11,6 +12,7 @@
<link rel="apple-touch-icon" sizes="140x140" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link href="{{ url_for('static', filename='css/libs/bootstrap.min.css') }}" rel="stylesheet" media="screen">
{% block header %}{% endblock %}
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet" media="screen">
<link href="{{ url_for('static', filename='css/upload.css') }}" rel="stylesheet" media="screen">
{% if g.current_theme == 1 %}
@ -22,8 +24,6 @@
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script>
<![endif]-->
{% block header %}{% endblock %}
</head>
<body class="{{ page }}" data-text="{{_('Home')}}" data-textback="{{_('Back')}}">
<!-- Static navbar -->

View file

@ -0,0 +1,39 @@
{% macro restrict_modal() %}
<div class="modal fade" id="restrictModal" tabindex="-1" role="dialog" aria-labelledby="restrictModalLabel">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title hidden" id="h1">{{_('Select allowed/denied Tags')}}</h4>
<h4 class="modal-title hidden" id="h2">{{_('Select allowed/denied Custom Column values')}}</h4>
<h4 class="modal-title hidden" id="h3">{{_('Select allowed/denied Tags of user')}}</h4>
<h4 class="modal-title hidden" id="h4">{{_('Select allowed/denied Custom Column values of user')}}</h4>
</div>
<div class="modal-body">
<table class="table table-no-bordered" id="restrict-elements-table" data-id-field="id" data-show-header="false" data-editable-mode="inline">
<thead>
<tr>
<th data-field="Element" id="Element" data-editable-type="text" data-editable="true" data-editable-title="{{_('Enter Tag')}}"></th>
<th data-field="type" id="type" data-visible="true"></th>
<th data-field="id" id="id" data-visible="false"></th>
<th data-align="right" data-formatter="RestrictionActions"></th>
</tr>
</thead>
</table>
<form id="add_restriction" action="" method="POST">
<div class="form-group required">
<label for="add_element">{{_('Add View Restriction')}}</label>
<input type="text" class="form-control" name="add_element" id="add_element" >
</div>
<div class="form-group required">
<input type="button" class="btn btn-default" value="{{_('Allow')}}" name="submit_allow" id="submit_allow" data-dismiss="static">
<input type="button" class="btn btn-default" value="{{_('Deny')}}" name="submit_deny" id="submit_restrict" data-dismiss="static">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" id="restrict_close" class="btn btn-default" data-dismiss="modal">{{_('Close')}}</button>
</div>
</div>
</div>
</div>
{% endmacro %}

View file

@ -57,7 +57,13 @@
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if kobo_support and not new_user %}
<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">
{% for element in sidebar %}
@ -73,6 +79,10 @@
<input type="checkbox" name="Show_detail_random" id="Show_detail_random" {% if content.show_detail_random() %}checked{% endif %}>
<label for="Show_detail_random">{{_('Show random books in detail view')}}</label>
</div>
{% if ( g.user and g.user.role_admin() and not new_user ) %}
<a href="#" id="get_user_tags" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/denied Tags')}}</a>
<a href="#" id="get_user_column_values" class="btn btn-default" data-toggle="modal" data-target="#restrictModal">{{_('Add allowed/denied custom column values')}}</a>
{% endif %}
</div>
<div class="col-sm-6">
{% if g.user and g.user.role_admin() and not profile %}
@ -82,10 +92,6 @@
<label for="admin_role">{{_('Admin user')}}</label>
</div>
{% endif %}
<div class="form-group">
<input type="checkbox" name="Show_mature_content" id="Show_mature_content" {% if content.mature_content %}checked{% endif %}>
<label for="Show_mature_content">{{_('Show mature content')}}</label>
</div>
<div class="form-group">
<input type="checkbox" name="download_role" id="download_role" {% if content.role_download() %}checked{% endif %}>
<label for="download_role">{{_('Allow Downloads')}}</label>
@ -146,4 +152,42 @@
</div>
{% endif %}
</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 %}
{% block modal %}
{{ restrict_modal() }}
{% endblock %}
{% block js %}
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/table.js') }}"></script>
{% endblock %}

View file

@ -156,6 +156,22 @@ class UserBase:
def show_detail_random(self):
return self.check_visibility(constants.DETAIL_RANDOM)
def list_denied_tags(self):
mct = self.denied_tags.split(",")
return [t.strip() for t in mct]
def list_allowed_tags(self):
mct = self.allowed_tags.split(",")
return [t.strip() for t in mct]
def list_denied_column_values(self):
mct = self.denied_column_value.split(",")
return [t.strip() for t in mct]
def list_allowed_column_values(self):
mct = self.allowed_column_value.split(",")
return [t.strip() for t in mct]
def __repr__(self):
return '<User %r>' % self.nickname
@ -178,6 +194,11 @@ class User(UserBase, Base):
sidebar_view = Column(Integer, default=1)
default_language = Column(String(3), default="all")
mature_content = Column(Boolean, default=True)
denied_tags = Column(String, default="")
allowed_tags = Column(String, default="")
denied_column_value = Column(String, default="")
allowed_column_value = Column(String, default="")
remote_auth_token = relationship('RemoteAuthToken', backref='user', lazy='dynamic')
if oauth_support:
@ -213,9 +234,10 @@ class Anonymous(AnonymousUserMixin, UserBase):
self.locale = data.locale
self.mature_content = data.mature_content
self.kindle_mail = data.kindle_mail
# settings = session.query(config).first()
# self.anon_browse = settings.config_anonbrowse
self.denied_tags = data.denied_tags
self.allowed_tags = data.allowed_tags
self.denied_column_value = data.denied_column_value
self.allowed_column_value = data.allowed_column_value
def role_admin(self):
return False
@ -311,6 +333,7 @@ class RemoteAuthToken(Base):
user_id = Column(Integer, ForeignKey('user.id'))
verified = Column(Boolean, default=False)
expiration = Column(DateTime)
token_type = Column(Integer, default=0)
def __init__(self):
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
@ -342,6 +365,15 @@ def migrate_Database(session):
conn.execute("ALTER TABLE registration ADD column 'allow' INTEGER")
conn.execute("update registration set 'allow' = 1")
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
cnt = session.query(Registration).count()
if not cnt:
@ -378,12 +410,19 @@ def migrate_Database(session):
'side_autor': constants.SIDEBAR_AUTHOR,
'detail_random': constants.DETAIL_RANDOM})
session.commit()
try:
'''try:
session.query(exists().where(User.mature_content)).scalar()
except exc.OperationalError:
conn = engine.connect()
conn.execute("ALTER TABLE user ADD column `mature_content` INTEGER DEFAULT 1")
conn.execute("ALTER TABLE user ADD column `mature_content` INTEGER DEFAULT 1")'''
try:
session.query(exists().where(User.denied_tags)).scalar()
except exc.OperationalError: # Database is not compatible, some columns are missing
conn = engine.connect()
conn.execute("ALTER TABLE user ADD column `denied_tags` String DEFAULT ''")
conn.execute("ALTER TABLE user ADD column `allowed_tags` String DEFAULT ''")
conn.execute("ALTER TABLE user ADD column `denied_column_value` DEFAULT ''")
conn.execute("ALTER TABLE user ADD column `allowed_column_value` DEFAULT ''")
if session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS).first() is None:
create_anonymous_user(session)
try:
@ -424,7 +463,8 @@ def migrate_Database(session):
def clean_database(session):
# Remove expired remote login tokens
now = datetime.datetime.now()
session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).delete()
session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).\
filter(RemoteAuthToken.token_type !=1 ).delete()
session.commit()

View file

@ -27,7 +27,7 @@ import datetime
import json
import mimetypes
import traceback
import sys
import binascii
from babel import Locale as LC
from babel.dates import format_date
@ -42,7 +42,7 @@ from werkzeug.exceptions import default_exceptions
from werkzeug.datastructures import Headers
from werkzeug.security import generate_password_hash, check_password_hash
from . import constants, config, logger, isoLanguages, services, worker
from . import constants, logger, isoLanguages, services, worker
from . import searched_ids, lm, babel, db, ub, config, get_locale, app
from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download
from .helper import common_filters, get_search_results, fill_indexpage, speaking_language, check_valid_domain, \
@ -54,7 +54,8 @@ from .redirect import redirect_back
feature_support = {
'ldap': False, # bool(services.ldap),
'goodreads': bool(services.goodreads_support)
'goodreads': bool(services.goodreads_support),
'kobo': bool(services.kobo)
}
try:
@ -149,7 +150,7 @@ def load_user_from_auth_header(header_val):
header_val = base64.b64decode(header_val).decode('utf-8')
basic_username = header_val.split(':')[0]
basic_password = header_val.split(':')[1]
except (TypeError, UnicodeDecodeError):
except (TypeError, UnicodeDecodeError, binascii.Error):
pass
user = _fetch_user_by_name(basic_username)
if user and check_password_hash(str(user.password), basic_password):
@ -459,12 +460,6 @@ def get_matching_tags():
if len(exclude_tag_inputs) > 0:
for tag in exclude_tag_inputs:
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))
'''if len(include_extension_inputs) > 0:
for tag in exclude_tag_inputs:
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))
if len(exclude_extension_inputs) > 0:
for tag in exclude_tag_inputs:
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))'''
for book in q:
for tag in book.tags:
if tag.id not in tag_dict['tags']:
@ -966,11 +961,13 @@ def advanced_search():
return render_title_template('search.html', searchterm=searchterm,
entries=q, title=_(u"search"), page="search")
# prepare data for search-form
# tags = db.session.query(db.Tags).order_by(db.Tags.name).all()
tags = db.session.query(db.Tags).filter(tags_filters()).order_by(db.Tags.name).all()
series = db.session.query(db.Series).order_by(db.Series.name).all()
extensions = db.session.query(db.Data) \
tags = db.session.query(db.Tags).join(db.books_tags_link).join(db.Books).filter(common_filters())\
.group_by(text('books_tags_link.tag')).order_by(db.Tags.name).all()
series = db.session.query(db.Series).join(db.books_series_link).join(db.Books).filter(common_filters())\
.group_by(text('books_series_link.series')).order_by(db.Series.name).filter(common_filters()).all()
extensions = db.session.query(db.Data).join(db.Books).filter(common_filters())\
.group_by(db.Data.format).order_by(db.Data.format).all()
if current_user.filter_language() == u"all":
languages = speaking_language()
else:
@ -1091,10 +1088,10 @@ def register():
if not to_save["nickname"] or not to_save["email"]:
flash(_(u"Please fill out all fields!"), category="error")
return render_title_template('register.html', title=_(u"register"), page="register")
existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"]
.lower()).first()
existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()).first()
if not existing_user and not existing_email:
content = ub.User()
# content.password = generate_password_hash(to_save["password"])
@ -1105,7 +1102,7 @@ def register():
content.password = generate_password_hash(password)
content.role = config.config_default_role
content.sidebar_view = config.config_default_show
content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT)
#content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT)
try:
ub.session.add(content)
ub.session.commit()
@ -1294,10 +1291,12 @@ def profile():
downloads = list()
languages = speaking_language()
translations = babel.list_translations() + [LC('en')]
kobo_support = feature_support['kobo'] and config.config_kobo_sync
if feature_support['oauth']:
oauth_status = get_oauth_status()
else:
oauth_status = None
for book in current_user.downloads:
downloadBook = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
if downloadBook:
@ -1312,11 +1311,14 @@ 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 "allowed_tags" in to_save and to_save["allowed_tags"] != current_user.allowed_tags:
current_user.allowed_tags = to_save["allowed_tags"].strip()
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")
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
kobo_support=kobo_support,
registered_oauth=oauth_check, oauth_status=oauth_status)
if "nickname" in to_save and to_save["nickname"] != current_user.nickname:
# Query User nickname, if not existing, change
@ -1327,6 +1329,7 @@ def profile():
return render_title_template("user_edit.html",
translations=translations,
languages=languages,
kobo_support=kobo_support,
new_user=0, content=current_user,
downloads=downloads,
registered_oauth=oauth_check,
@ -1349,7 +1352,7 @@ def profile():
if "Show_detail_random" in to_save:
current_user.sidebar_view += constants.DETAIL_RANDOM
current_user.mature_content = "Show_mature_content" in to_save
#current_user.mature_content = "Show_mature_content" in to_save
try:
ub.session.commit()
@ -1358,13 +1361,13 @@ def profile():
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.")
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
translations=translations,
translations=translations, kobo_support=kobo_support,
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
registered_oauth=oauth_check, oauth_status=oauth_status)
flash(_(u"Profile updated"), category="success")
log.debug(u"Profile updated")
return render_title_template("user_edit.html", translations=translations, profile=1, languages=languages,
content=current_user, downloads=downloads,
content=current_user, downloads=downloads, kobo_support=kobo_support,
title= _(u"%(name)s's profile", name=current_user.nickname),
page="me", registered_oauth=oauth_check, oauth_status=oauth_status)

View file

@ -32,3 +32,7 @@ rarfile>=2.7
# other
natsort>=2.2.0,<7.1.0
git+https://github.com/OzzieIsaacs/comicapi.git@ad8bfe5a1c31db882480433f86db2c5c57634a3f#egg=comicapi
#Kobo integration
jsonschema>=3.2.0,<3.3.0

File diff suppressed because it is too large Load diff