diff --git a/cps.py b/cps.py index ca7d7230..3601e4b6 100755 --- a/cps.py +++ b/cps.py @@ -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() diff --git a/cps/about.py b/cps/about.py index 8a77aae0..d362da9b 100644 --- a/cps/about.py +++ b/cps/about.py @@ -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()) diff --git a/cps/admin.py b/cps/admin.py index 7ab28fb9..54742345 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -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/", 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/", 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/", 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//", defaults={'user_id': '0'}) +@admi.route("/ajax/listrestriction/") +@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") diff --git a/cps/config_sql.py b/cps/config_sql.py index c00bc213..d6edcaf0 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -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 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 mature_content_tags(self): - mct = self.config_mature_content_tags.split(",") + 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 diff --git a/cps/db.py b/cps/db.py index 67697468..f40e0cda 100755 --- a/cps/db.py +++ b/cps/db.py @@ -25,7 +25,7 @@ import ast from sqlalchemy import create_engine from sqlalchemy import Table, Column, ForeignKey -from sqlalchemy import String, Integer, Boolean, 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) diff --git a/cps/helper.py b/cps/helper.py index 0e1c1fcd..3617843c 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -448,32 +448,46 @@ def delete_book(book, calibrepath, book_format): return delete_book_file(book, calibrepath, book_format) +def get_cover_on_failure(use_generic_cover): + if use_generic_cover: + return send_from_directory(_STATIC_DIR, "generic_cover.jpg") + else: + return None + def get_book_cover(book_id): - book = db.session.query(db.Books).filter(db.Books.id == book_id).first() - if book.has_cover: + 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 diff --git a/cps/kobo.py b/cps/kobo.py new file mode 100644 index 00000000..2b09b562 --- /dev/null +++ b/cps/kobo.py @@ -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 . + +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/") +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//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("//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/", methods=["DELETE", "GET"]) +@kobo.route("/v1/library//state", methods=["PUT"]) +@kobo.route("/v1/library/tags", methods=["POST"]) +@kobo.route("/v1/library/tags/", methods=["POST"]) +@kobo.route("/v1/library/tags/", 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/", 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/", 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//prices", methods=["GET", "POST"]) +@kobo.route("/v1/products//recommendations", methods=["GET", "POST"]) +@kobo.route("/v1/products//nextread", methods=["GET", "POST"]) +@kobo.route("/v1/products//reviews", methods=["GET", "POST"]) +@kobo.route("/v1/products/books/", 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", + } diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py new file mode 100644 index 00000000..7424f13f --- /dev/null +++ b/cps/kobo_auth.py @@ -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 . + + +"""This module is used to control authentication/authorization of Kobo sync requests. +This module also includes research notes into the auth protocol used by Kobo devices. + +Log-in: +When first booting a Kobo device the user must sign into a Kobo (or affiliate) account. +Upon successful sign-in, the user is redirected to + https://auth.kobobooks.com/CrossDomainSignIn?id= +which serves the following response: + . +And triggers the insertion of a userKey into the device's User table. + +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//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/") +@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/") +@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 "" diff --git a/cps/reverseproxy.py b/cps/reverseproxy.py index a4a56700..42b64050 100644 --- a/cps/reverseproxy.py +++ b/cps/reverseproxy.py @@ -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 diff --git a/cps/server.py b/cps/server.py index 74c591ec..576e0774 100755 --- a/cps/server.py +++ b/cps/server.py @@ -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 diff --git a/cps/services/SyncToken.py b/cps/services/SyncToken.py new file mode 100644 index 00000000..21f16acc --- /dev/null +++ b/cps/services/SyncToken.py @@ -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 . + +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) diff --git a/cps/services/__init__.py b/cps/services/__init__.py index d468d0b7..2eb82f0d 100644 --- a/cps/services/__init__.py +++ b/cps/services/__init__.py @@ -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 diff --git a/cps/shelf.py b/cps/shelf.py index 6e8c4b9f..afee1eaa 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -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") diff --git a/cps/static/css/style.css b/cps/static/css/style.css index 93dfcb3e..0850e10a 100644 --- a/cps/static/css/style.css +++ b/cps/static/css/style.css @@ -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;} diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 2b2716bf..8f54ba45 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -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"); }); diff --git a/cps/static/js/table.js b/cps/static/js/table.js index 12c07102..de570ed8 100644 --- a/cps/static/js/table.js +++ b/cps/static/js/table.js @@ -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) { "" ].join(""); } + +/* Function for deleting domain restrictions */ +function RestrictionActions (value, row, index) { + return [ + "
", + "", + "
" + ].join(""); +} diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 0d28b8ea..7fcf44a2 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -1,6 +1,6 @@ {% extends "layout.html" %} {% block body %} -
+

{{title}}

@@ -71,7 +71,7 @@
- + {% if show_back_button %}

@@ -169,6 +169,18 @@

+ {% if feature_support['kobo'] %} +
+ + +
+
+
+ + +
+
+ {% endif %} {% if feature_support['goodreads'] %}
@@ -322,11 +334,14 @@
+ {% endif %}
+ {% if not show_login_button %} + {% endif %} {% if show_back_button %} {{_('Back')}} {% endif %} diff --git a/cps/templates/config_view_edit.html b/cps/templates/config_view_edit.html index bc6defa4..6d547377 100644 --- a/cps/templates/config_view_edit.html +++ b/cps/templates/config_view_edit.html @@ -1,4 +1,8 @@ {% extends "layout.html" %} +{% block header %} + + +{% endblock %} {% block body %}

{{title}}

@@ -51,16 +55,19 @@ {% endfor %}
+
+ + +
-
- -
@@ -134,14 +141,11 @@ -
- - -
+ {{_('Add allowed/denied Tags')}} + {{_('Add allowed/denied custom column values')}} -
{{_('Back')}} @@ -149,6 +153,9 @@
{% endblock %} +{% block modal %} +{{ restrict_modal() }} +{% endblock %} {% block js %} + + + + {% endblock %} diff --git a/cps/templates/generate_kobo_auth_url.html b/cps/templates/generate_kobo_auth_url.html new file mode 100644 index 00000000..fea9aecb --- /dev/null +++ b/cps/templates/generate_kobo_auth_url.html @@ -0,0 +1,14 @@ +{% extends "fragment.html" %} +{% block body %} +
+

+ {{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}} +

+

+ {% if not warning %}{{_('api_endpoint=')}}{{kobo_auth_url}}{% else %}{{warning}}{% endif %} +

+

+ {{_('Please note that every visit to this current page invalidates any previously generated Authentication url for this user.')}} +

+
+{% endblock %} diff --git a/cps/templates/layout.html b/cps/templates/layout.html index 0e235ed3..07a9b5b6 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -1,3 +1,4 @@ +{% from 'modal_restriction.html' import restrict_modal %} @@ -11,6 +12,7 @@ + {% block header %}{% endblock %} {% if g.current_theme == 1 %} @@ -22,8 +24,6 @@ - - {% block header %}{% endblock %} diff --git a/cps/templates/modal_restriction.html b/cps/templates/modal_restriction.html new file mode 100644 index 00000000..ede585d7 --- /dev/null +++ b/cps/templates/modal_restriction.html @@ -0,0 +1,39 @@ +{% macro restrict_modal() %} + +{% endmacro %} diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html index 247f9159..00763ff2 100644 --- a/cps/templates/user_edit.html +++ b/cps/templates/user_edit.html @@ -57,7 +57,13 @@ {% endif %} {% endfor %} - + {% endif %} + {% if kobo_support and not new_user %} + +
+ {{_('Create/View')}} + +
{% endif %}
{% for element in sidebar %} @@ -73,6 +79,10 @@
+ {% if ( g.user and g.user.role_admin() and not new_user ) %} + {{_('Add allowed/denied Tags')}} + {{_('Add allowed/denied custom column values')}} + {% endif %}
{% if g.user and g.user.role_admin() and not profile %} @@ -82,10 +92,6 @@
{% endif %} -
- - -
@@ -146,4 +152,42 @@
{% endif %} + + + + +{% endblock %} +{% block modal %} +{{ restrict_modal() }} +{% endblock %} +{% block js %} + + + + {% endblock %} diff --git a/cps/ub.py b/cps/ub.py index 717e201a..ac43b89f 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -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 '' % 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() diff --git a/cps/web.py b/cps/web.py index 97a3b0be..56e17d2e 100644 --- a/cps/web.py +++ b/cps/web.py @@ -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) diff --git a/optional-requirements.txt b/optional-requirements.txt index 5aac831b..5bacdd69 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -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 + diff --git a/test/Calibre-Web TestSummary.html b/test/Calibre-Web TestSummary.html index 61cc0c49..9f4117c4 100644 --- a/test/Calibre-Web TestSummary.html +++ b/test/Calibre-Web TestSummary.html @@ -1,67 +1,69 @@ - - Test Report - - + Calibre-Web Tests + + - - - - - - - - - - - + + + + + + + + + -
-
-

Test Report

-
- -
-
-

Start Time: 2020-01-18 11:05:44.056481

- -
-
-

Stop Time: 2020-01-18 11:39:51.113755

+
+

Calibre-Web Tests

-
-

Duration: 0:34:07.057274

- +
+
+
+
+
+ +
+
+
+
+
+
+

Start Time: 2020-02-16 16:10:19

+
+
+
+
+

Stop Time: 2020-02-16 16:50:18

+
+
+
+
+

Duration: 2160.47 s

+
+
+
-
+
-
-
- +
+
-
- -
-
All Calibre-Web tests
-
- - -
+
+ @@ -76,7 +78,6 @@
- @@ -95,2377 +96,3007 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - +
Skip View
test_anonymous.test_anonymous1010000 - Detail -
-
test_guest_about
-
PASS
-
test_guest_change_visibility_category
-
PASS
-
test_guest_change_visibility_hot
-
PASS
-
test_guest_change_visibility_language
-
PASS
-
test_guest_change_visibility_publisher
-
PASS
-
test_guest_change_visibility_rated
-
PASS
-
test_guest_change_visibility_series
-
PASS
-
test_guest_random_books_available
-
PASS
-
test_guest_visibility_read
-
PASS
-
test_guest_visibility_sidebar
-
PASS
test_cli.test_cli65001 - Detail -
-
test_already_started
-
PASS
-
test_cli_SSL_files
-
PASS
-
test_cli_different_folder
-
PASS
-
test_cli_different_settings_database
-
PASS
-
test_cli_gdrive_location
-
-
- SKIP -
- - - -
-
test_environ_port_setting
-
PASS
test_ebook_convert.test_ebook_convert1111000 - Detail -
-
test_SSL_smtp_setup_error
-
PASS
-
test_STARTTLS_smtp_setup_error
-
PASS
-
test_convert_deactivate
-
PASS
-
test_convert_email
-
-
- PASS -
- - - -
-
test_convert_failed_and_email
-
-
- PASS -
- - - -
-
test_convert_only
-
PASS
-
test_convert_parameter
-
PASS
-
test_convert_wrong_excecutable
-
PASS
-
test_email_failed
-
-
- PASS -
- - - -
-
test_email_only
-
-
- PASS -
- - - -
-
test_kindle_send_not_configured
-
PASS
test_edit_books.test_edit_books23120011 - Detail -
-
test_database_errors
-
-
- SKIP -
- - - -
-
test_delete_book
-
-
- SKIP -
- - - -
-
test_delete_format
-
-
- SKIP -
- - - -
-
test_edit_author
-
PASS
-
test_edit_category
-
PASS
-
test_edit_comments
-
PASS
-
test_edit_custom_bool
-
PASS
-
test_edit_custom_rating
-
PASS
-
test_edit_custom_single_select
-
PASS
-
test_edit_custom_text
-
PASS
-
test_edit_language
-
PASS
-
test_edit_publisher
-
PASS
-
test_edit_publishing_date
-
-
- SKIP -
- - - -
-
test_edit_rating
-
PASS
-
test_edit_series
-
PASS
-
test_edit_title
-
PASS
-
test_rename_uppercase_lowercase
-
-
- SKIP -
- - - -
-
test_typeahead_author
-
-
- SKIP -
- - - -
-
test_typeahead_language
-
-
- SKIP -
- - - -
-
test_typeahead_publisher
-
-
- SKIP -
- - - -
-
test_typeahead_series
-
-
- SKIP -
- - - -
-
test_typeahead_tag
-
-
- SKIP -
- - - -
-
test_upload_cover_hdd
-
-
- SKIP -
- - - -
test_edit_books_gdrive.test_edit_books_gdrive2200022 - Detail -
-
test_database_errors
-
-
- SKIP -
- - - -
-
test_delete_book
-
-
- SKIP -
- - - -
-
test_delete_format
-
-
- SKIP -
- - - -
-
test_edit_author
-
-
- SKIP -
- - - -
-
test_edit_category
-
-
- SKIP -
- - - -
-
test_edit_comments
-
-
- SKIP -
- - - -
-
test_edit_custom_bool
-
-
- SKIP -
- - - -
-
test_edit_custom_rating
-
-
- SKIP -
- - - -
-
test_edit_custom_single_select
-
-
- SKIP -
- - - -
-
test_edit_custom_text
-
-
- SKIP -
- - - -
-
test_edit_language
-
-
- SKIP -
- - - -
-
test_edit_publisher
-
-
- SKIP -
- - - -
-
test_edit_publishing_date
-
-
- SKIP -
- - - -
-
test_edit_rating
-
-
- SKIP -
- - - -
-
test_edit_series
-
-
- SKIP -
- - - -
-
test_edit_title
-
-
- SKIP -
- - - -
-
test_typeahead_author
-
-
- SKIP -
- - - -
-
test_typeahead_language
-
-
- SKIP -
- - - -
-
test_typeahead_publisher
-
-
- SKIP -
- - - -
-
test_typeahead_series
-
-
- SKIP -
- - - -
-
test_typeahead_tag
-
-
- SKIP -
- - - -
-
test_upload_cover_hdd
-
-
- SKIP -
- - - -
test_email_STARTTLS.test_STARTTLS22000 - Detail -
-
test_STARTTLS
-
-
- PASS -
- - - -
-
test_STARTTLS_SSL_setup_error
-
PASS
test_email_ssl.test_SSL44000 - Detail -
-
test_SSL_None_setup_error
-
PASS
-
test_SSL_STARTTLS_setup_error
-
PASS
-
test_SSL_logging_email
-
-
- PASS -
- - - -
-
test_SSL_only
-
-
- PASS -
- - - -
test_helper.calibre_helper1313000 - Detail -
-
test_author_sort
-
PASS
-
test_author_sort_comma
-
PASS
-
test_author_sort_junior
-
PASS
-
test_author_sort_oneword
-
PASS
-
test_author_sort_roman
-
PASS
-
test_check_Limit_Length
-
PASS
-
test_check_char_replacement
-
PASS
-
test_check_chinese_Characters
-
PASS
-
test_check_degEUR_replacement
-
PASS
-
test_check_doubleS
-
PASS
-
test_check_finish_Dot
-
PASS
-
test_check_high23
-
PASS
-
test_check_umlauts
-
PASS
test_logging.test_logging53101 - Detail -
-
test_debug_log
-
PASS
-
test_failed_login
-
PASS
-
test_failed_register
-
-
- SKIP -
- - - -
-
test_logfile_change
-
PASS
-
test_logfile_recover
-
-
- FAIL -
- -
test_anonymous.test_anonymous1111000 + Detail +
+
test_check_locale_guest
+
PASS
+
test_guest_about
+
PASS
+
test_guest_change_visibility_category
+
PASS
+
test_guest_change_visibility_hot
+
PASS
+
test_guest_change_visibility_language
+
PASS
+
test_guest_change_visibility_publisher
+
PASS
+
test_guest_change_visibility_rated
+
PASS
+
test_guest_change_visibility_series
+
PASS
+
test_guest_random_books_available
+
PASS
+
test_guest_visibility_read
+
PASS
+
test_guest_visibility_sidebar
+
PASS
test_cli.test_cli65001 + Detail +
+
test_already_started
+
PASS
+
test_cli_SSL_files
+
PASS
+
test_cli_different_folder
+
PASS
+
test_cli_different_settings_database
+
PASS
+
test_cli_gdrive_location
+
+
+ SKIP +
+ + + +
+
test_environ_port_setting
+
PASS
test_ebook_convert.test_ebook_convert1111000 + Detail +
+
test_SSL_smtp_setup_error
+
PASS
+
test_STARTTLS_smtp_setup_error
+
PASS
+
test_convert_deactivate
+
PASS
+
test_convert_email
+
PASS
+
test_convert_failed_and_email
+
PASS
+
test_convert_only
+
PASS
+
test_convert_parameter
+
PASS
+
test_convert_wrong_excecutable
+
PASS
+
test_email_failed
+
PASS
+
test_email_only
+
PASS
+
test_kindle_send_not_configured
+
PASS
test_edit_books.test_edit_books23120011 + Detail +
+
test_database_errors
+
+
+ SKIP +
+ + + +
+
test_delete_book
+
+
+ SKIP +
+ + + +
+
test_delete_format
+
+
+ SKIP +
+ + + +
+
test_edit_author
+
PASS
+
test_edit_category
+
PASS
+
test_edit_comments
+
PASS
+
test_edit_custom_bool
+
PASS
+
test_edit_custom_rating
+
PASS
+
test_edit_custom_single_select
+
PASS
+
test_edit_custom_text
+
PASS
+
test_edit_language
+
PASS
+
test_edit_publisher
+
PASS
+
test_edit_publishing_date
+
+
+ SKIP +
+ + + +
+
test_edit_rating
+
PASS
+
test_edit_series
+
PASS
+
test_edit_title
+
PASS
+
test_rename_uppercase_lowercase
+
+
+ SKIP +
+ + + +
+
test_typeahead_author
+
+
+ SKIP +
+ + + +
+
test_typeahead_language
+
+
+ SKIP +
+ + + +
+
test_typeahead_publisher
+
+
+ SKIP +
+ + + +
+
test_typeahead_series
+
+
+ SKIP +
+ + + +
+
test_typeahead_tag
+
+
+ SKIP +
+ + + +
+
test_upload_cover_hdd
+
+
+ SKIP +
+ + + +
test_edit_books_gdrive.test_edit_books_gdrive2200022 + Detail +
+
test_database_errors
+
+
+ SKIP +
+ + + +
+
test_delete_book
+
+
+ SKIP +
+ + + +
+
test_delete_format
+
+
+ SKIP +
+ + + +
+
test_edit_author
+
+
+ SKIP +
+ + + +
+
test_edit_category
+
+
+ SKIP +
+ + + +
+
test_edit_comments
+
+
+ SKIP +
+ + + +
+
test_edit_custom_bool
+
+
+ SKIP +
+ + + +
+
test_edit_custom_rating
+
+
+ SKIP +
+ + + +
+
test_edit_custom_single_select
+
+
+ SKIP +
+ + + +
+
test_edit_custom_text
+
+
+ SKIP +
+ + + +
+
test_edit_language
+
+
+ SKIP +
+ + + +
+
test_edit_publisher
+
+
+ SKIP +
+ + + +
+
test_edit_publishing_date
+
+
+ SKIP +
+ + + +
+
test_edit_rating
+
+
+ SKIP +
+ + + +
+
test_edit_series
+
+
+ SKIP +
+ + + +
+
test_edit_title
+
+
+ SKIP +
+ + + +
+
test_typeahead_author
+
+
+ SKIP +
+ + + +
+
test_typeahead_language
+
+
+ SKIP +
+ + + +
+
test_typeahead_publisher
+
+
+ SKIP +
+ + + +
+
test_typeahead_series
+
+
+ SKIP +
+ + + +
+
test_typeahead_tag
+
+
+ SKIP +
+ + + +
+
test_upload_cover_hdd
+
+
+ SKIP +
+ + + +
test_email_STARTTLS.test_STARTTLS22000 + Detail +
+
test_STARTTLS
+
PASS
+
test_STARTTLS_SSL_setup_error
+
PASS
test_email_ssl.test_SSL44000 + Detail +
+
test_SSL_None_setup_error
+
PASS
+
test_SSL_STARTTLS_setup_error
+
PASS
+
test_SSL_logging_email
+
PASS
+
test_SSL_only
+
PASS
test_helper.calibre_helper1313000 + Detail +
+
test_author_sort
+
PASS
+
test_author_sort_comma
+
PASS
+
test_author_sort_junior
+
PASS
+
test_author_sort_oneword
+
PASS
+
test_author_sort_roman
+
PASS
+
test_check_Limit_Length
+
PASS
+
test_check_char_replacement
+
PASS
+
test_check_chinese_Characters
+
PASS
+
test_check_degEUR_replacement
+
PASS
+
test_check_doubleS
+
PASS
+
test_check_finish_Dot
+
PASS
+
test_check_high23
+
PASS
+
test_check_umlauts
+
PASS
test_logging.test_logging63201 + Detail +
+
test_debug_log
+
PASS
+
test_failed_login
+
PASS
+
test_failed_register
+
+
+ SKIP +
+ + + +
+
test_logfile_change
+
PASS
+
test_logfile_recover
+
+
+ FAIL +
+ + - -
test_login.test_login77000 - Detail -
-
test_login_capital_letters_user_unicode_password_passwort
-
PASS
-
test_login_delete_admin
-
PASS
-
test_login_empty_password
-
PASS
-
test_login_locale_select
-
PASS
-
test_login_protected
-
PASS
-
test_login_unicode_user_space_end_passwort
-
PASS
-
test_login_user_with_space_passwort_end_space
-
PASS
test_opds_feed.test_opds_feed1620014 - Detail -
-
test_opds
-
PASS
-
test_opds_author
-
-
- SKIP -
- - - -
-
test_opds_calibre_companion
-
-
- SKIP -
- - - -
-
test_opds_cover
-
-
- SKIP -
- - - -
-
test_opds_download_book
-
-
- SKIP -
- - - -
-
test_opds_guest_user
-
PASS
-
test_opds_hot
-
-
- SKIP -
- - - -
-
test_opds_language
-
-
- SKIP -
- - - -
-
test_opds_non_admin
-
-
- SKIP -
- - - -
-
test_opds_paging
-
-
- SKIP -
- - - -
-
test_opds_publisher
-
-
- SKIP -
- - - -
-
test_opds_random
-
-
- SKIP -
- - - -
-
test_opds_read_unread
-
-
- SKIP -
- - - -
-
test_opds_search
-
-
- SKIP -
- - - -
-
test_opds_series
-
-
- SKIP -
- - - -
-
test_opds_shelf_access
-
-
- SKIP -
- - - -
test_register.test_register66000 - Detail -
-
test_forgot_password
-
-
- PASS -
- - - -
-
test_limit_domain
-
-
- PASS -
- - - -
-
test_register_no_server
-
PASS
-
test_registering_user
-
-
- PASS -
- - - -
-
test_registering_user_fail
-
-
- PASS -
- - - -
-
test_user_change_password
-
-
- PASS -
- - - -
test_shelf.test_shelf75200 - Detail -
-
test_delete_book_of_shelf
-
PASS
-
test_private_shelf
-
PASS
-
test_public_private_shelf
-
PASS
-
test_public_shelf
-
-
- FAIL -
- - + +
+
test_logviewer
+
+
+ FAIL +
+ + + +
test_login.test_login86002 + Detail +
+
test_digest_login
+
+
+ SKIP +
+ + + +
+
test_login_capital_letters_user_unicode_password_passwort
+
PASS
+
test_login_delete_admin
+
PASS
+
test_login_empty_password
+
PASS
+
test_login_locale_select
+
+
+ SKIP +
+ + + +
+
test_login_protected
+
PASS
+
test_login_unicode_user_space_end_passwort
+
PASS
+
test_login_user_with_space_passwort_end_space
+
PASS
test_opds_feed.test_opds_feed1620014 + Detail +
+
test_opds
+
PASS
+
test_opds_author
+
+
+ SKIP +
+ + + +
+
test_opds_calibre_companion
+
+
+ SKIP +
+ + + +
+
test_opds_cover
+
+
+ SKIP +
+ + + +
+
test_opds_download_book
+
+
+ SKIP +
+ + + +
+
test_opds_guest_user
+
PASS
+
test_opds_hot
+
+
+ SKIP +
+ + + +
+
test_opds_language
+
+
+ SKIP +
+ + + +
+
test_opds_non_admin
+
+
+ SKIP +
+ + + +
+
test_opds_paging
+
+
+ SKIP +
+ + + +
+
test_opds_publisher
+
+
+ SKIP +
+ + + +
+
test_opds_random
+
+
+ SKIP +
+ + + +
+
test_opds_read_unread
+
+
+ SKIP +
+ + + +
+
test_opds_search
+
+
+ SKIP +
+ + + +
+
test_opds_series
+
+
+ SKIP +
+ + + +
+
test_opds_shelf_access
+
+
+ SKIP +
+ + + +
test_register.test_register66000 + Detail +
+
test_forgot_password
+
PASS
+
test_limit_domain
+
PASS
+
test_register_no_server
+
PASS
+
test_registering_user
+
PASS
+
test_registering_user_fail
+
PASS
+
test_user_change_password
+
PASS
test_shelf.test_shelf86101 + Detail +
+
test_arrange_shelf
+
PASS
+
test_delete_book_of_shelf
+
PASS
+
test_private_shelf
+
PASS
+
test_public_private_shelf
+
PASS
+
test_public_shelf
+
+
+ FAIL +
+ + - -
-
test_rename_shelf
-
PASS
-
test_shelf_database_change
-
-
- FAIL -
- - - -
-
test_shelf_long_name
-
PASS
test_updater.test_updater10001 - Detail -
-
test_updater
-
-
- SKIP -
- - - -
test_user_template.test_user_template1414000 - Detail -
-
test_author_user_template
-
PASS
-
test_best_user_template
-
PASS
-
test_category_user_template
-
PASS
-
test_content_restriction_settings
-
PASS
-
test_detail_random_user_template
-
PASS
-
test_hot_user_template
-
PASS
-
test_language_user_template
-
PASS
-
test_limit_book_languages
-
PASS
-
test_publisher_user_template
-
PASS
-
test_random_user_template
-
PASS
-
test_read_user_template
-
PASS
-
test_recent_user_template
-
PASS
-
test_series_user_template
-
PASS
-
test_ui_language_settings
-
PASS
test_visiblilitys.calibre_web_visibilitys1616000 - Detail -
-
test_about
-
PASS
-
test_admin_SMTP_Settings
-
PASS
-
test_admin_add_user
-
PASS
-
test_admin_change_password
-
PASS
-
test_admin_change_visibility_authors
-
PASS
-
test_admin_change_visibility_category
-
PASS
-
test_admin_change_visibility_hot
-
PASS
-
test_admin_change_visibility_language
-
PASS
-
test_admin_change_visibility_publisher
-
PASS
-
test_admin_change_visibility_rated
-
PASS
-
test_admin_change_visibility_read
-
PASS
-
test_admin_change_visibility_series
-
PASS
-
test_checked_logged_in
-
PASS
-
test_random_books_available
-
PASS
-
test_user_email_available
-
PASS
-
test_user_visibility_sidebar
-
PASS
+
test_rename_shelf
+
PASS
+
test_shelf_database_change
+
+
+ SKIP +
+ + + +
+
test_shelf_long_name
+
PASS
test_updater.test_updater10001 + Detail +
+
test_updater
+
+
+ SKIP +
+ + + +
test_user_template.test_user_template1717000 + Detail +
+
test_allow_column_restriction
+
PASS
+
test_allow_tag_restriction
+
PASS
+
test_author_user_template
+
PASS
+
test_best_user_template
+
PASS
+
test_category_user_template
+
PASS
+
test_deny_column_restriction
+
PASS
+
test_deny_tag_restriction
+
PASS
+
test_detail_random_user_template
+
PASS
+
test_hot_user_template
+
PASS
+
test_language_user_template
+
PASS
+
test_limit_book_languages
+
PASS
+
test_publisher_user_template
+
PASS
+
test_random_user_template
+
PASS
+
test_read_user_template
+
PASS
+
test_recent_user_template
+
PASS
+
test_series_user_template
+
PASS
+
test_ui_language_settings
+
PASS
test_visiblilitys.calibre_web_visibilitys2222000 + Detail +
+
test_about
+
PASS
+
test_admin_SMTP_Settings
+
PASS
+
test_admin_add_user
+
PASS
+
test_admin_change_password
+
PASS
+
test_admin_change_visibility_authors
+
PASS
+
test_admin_change_visibility_category
+
PASS
+
test_admin_change_visibility_file_formats
+
PASS
+
test_admin_change_visibility_hot
+
PASS
+
test_admin_change_visibility_language
+
PASS
+
test_admin_change_visibility_publisher
+
PASS
+
test_admin_change_visibility_rated
+
PASS
+
test_admin_change_visibility_rating
+
PASS
+
test_admin_change_visibility_read
+
PASS
+
test_admin_change_visibility_series
+
PASS
+
test_allow_columns
+
PASS
+
test_allow_tags
+
PASS
+
test_checked_logged_in
+
PASS
+
test_random_books_available
+
PASS
+
test_restrict_columns
+
PASS
+
test_restrict_tags
+
PASS
+
test_user_email_available
+
PASS
+
test_user_visibility_sidebar
+
PASS
Total163110176120 3 05053  
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Program libraryInstalled Version
PlatformLinux 5.3.0-28-generic #30~18.04.1-Ubuntu SMP Fri Jan 17 06:14:09 UTC 2020 x86_64 x86_64
Python3.7.5 (default, Nov 7 2019, 10:50:52) +[GCC 8.3.0]
Wand0.5.9
uritemplate3.0.1
tornado6.0.3
SQLAlchemy1.3.13
six1.14.0
singledispatch3.4.0.3
rsa3.4.2
rarfile3.1
python-Levenshtein0.12.0
PyPDF21.26.0
PyDrive1.3.1
pyasn10.4.8
pyasn1-modules0.2.8
oauth2client4.1.3
natsort7.0.1
lxml4.5.0
jsonschema3.2.0
iso-6390.4.5
greenlet0.4.15
google-api-python-client1.7.11
goodreads0.3.2
Flask1.1.1
Flask-Principal0.4.0
Flask-Login0.5.0
Flask-Babel1.0.0
Babel2.8.0
requests2.18.4
PyYAML3.12
pytz2018.3
Pillow5.1.0
httplib20.9.2
+
+
+
+
- + \ No newline at end of file