diff --git a/README.md b/README.md index 849cd1fd..88db2126 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,12 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d - Send eBooks to Kindle devices with the click of a button - Sync your Kobo devices through Calibre-Web with your Calibre library - Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz) -- Upload new books in many formats -- Support for Calibre custom columns +- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b) +- Support for Calibre Custom Columns - Ability to hide content based on categories and Custom Column content per user - Self-update capability - "Magic Link" login to make it easy to log on eReaders -- Login via google/github oauth and via proxy authentication +- Login via LDAP, google/github oauth and via proxy authentication ## Quick start diff --git a/cps/about.py b/cps/about.py index fd52ca7b..dfc6c502 100644 --- a/cps/about.py +++ b/cps/about.py @@ -69,6 +69,7 @@ _VERSIONS = OrderedDict( pytz=pytz.__version__, Unidecode = unidecode_version, Flask_SimpleLDAP = u'installed' if bool(services.ldap) else u'not installed', + python_LDAP = services.ldapVersion if bool(services.ldapVersion) 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', ) diff --git a/cps/admin.py b/cps/admin.py index 726071d3..b2f740bc 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -504,7 +504,7 @@ def _configuration_update_helper(): with open(gdriveutils.CLIENT_SECRETS, 'r') as settings: gdrive_secrets = json.load(settings)['web'] if not gdrive_secrets: - return _configuration_result('client_secrets.json is not configured for web application') + return _configuration_result(_('client_secrets.json Is Not Configured For Web Application')) gdriveutils.update_settings( gdrive_secrets['client_id'], gdrive_secrets['client_secret'], @@ -520,11 +520,11 @@ def _configuration_update_helper(): reboot_required |= _config_string("config_keyfile") if config.config_keyfile and not os.path.isfile(config.config_keyfile): - return _configuration_result('Keyfile location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('Keyfile Location is not Valid, Please Enter Correct Path'), gdriveError) reboot_required |= _config_string("config_certfile") if config.config_certfile and not os.path.isfile(config.config_certfile): - return _configuration_result('Certfile location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('Certfile Location is not Valid, Please Enter Correct Path'), gdriveError) _config_checkbox_int("config_uploading") _config_checkbox_int("config_anonbrowse") @@ -542,45 +542,57 @@ def _configuration_update_helper(): #LDAP configurator, if config.config_login_type == constants.LOGIN_LDAP: - _config_string("config_ldap_provider_url") - _config_int("config_ldap_port") + reboot_required |= _config_string("config_ldap_provider_url") + reboot_required |= _config_int("config_ldap_port") # _config_string("config_ldap_schema") - _config_string("config_ldap_dn") - _config_string("config_ldap_user_object") + reboot_required |= _config_string("config_ldap_dn") + reboot_required |= _config_string("config_ldap_serv_username") + reboot_required |= _config_string("config_ldap_user_object") + reboot_required |= _config_string("config_ldap_group_object_filter") + reboot_required |= _config_string("config_ldap_group_members_field") + reboot_required |= _config_checkbox("config_ldap_openldap") + reboot_required |= _config_int("config_ldap_encryption") + reboot_required |= _config_string("config_ldap_cert_path") + _config_string("config_ldap_group_name") + if "config_ldap_serv_password" in to_save and to_save["config_ldap_serv_password"] != "": + reboot_required |= 1 + config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') + config.save() + if not config.config_ldap_provider_url \ or not config.config_ldap_port \ or not config.config_ldap_dn \ or not config.config_ldap_user_object: - return _configuration_result('Please enter a LDAP provider, ' - 'port, DN and user object identifier', gdriveError) + return _configuration_result(_('Please Enter a LDAP Provider, ' + 'Port, DN and User Object Identifier'), gdriveError) - _config_string("config_ldap_serv_username") - if "config_ldap_serv_password" in to_save and to_save["config_ldap_serv_password"]: - config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') - if not config.config_ldap_serv_username and not config.config_ldap_serv_password: - return _configuration_result('Please enter a LDAP service account and password', gdriveError) + if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password): + return _configuration_result('Please Enter a LDAP Service Account and Password', gdriveError) - _config_string("config_ldap_group_object_filter") - _config_string("config_ldap_group_members_field") - _config_string("config_ldap_group_name") #_config_checkbox("config_ldap_use_ssl") #_config_checkbox("config_ldap_use_tls") - _config_int("config_ldap_encryption") - _config_checkbox("config_ldap_openldap") + # reboot_required |= _config_checkbox("config_ldap_openldap") # _config_checkbox("config_ldap_require_cert") - _config_string("config_ldap_cert_path") - if config.config_ldap_group_object_filter.count("%s") != 1: - return _configuration_result('LDAP Group Object Filter Needs to Have One "%s" Format Identifier', - gdriveError) + if config.config_ldap_group_object_filter: + if config.config_ldap_group_object_filter.count("%s") != 1: + return _configuration_result(_('LDAP Group Object Filter Needs to Have One "%s" Format Identifier'), + gdriveError) + if config.config_ldap_group_object_filter.count("(") != config.config_ldap_group_object_filter.count(")"): + return _configuration_result(_('LDAP Group Object Filter Has Unmatched Parenthesis'), + gdriveError) if config.config_ldap_user_object.count("%s") != 1: - return _configuration_result('LDAP User Object Filter needs to Have One "%s" Format Identifier', + return _configuration_result(_('LDAP User Object Filter needs to Have One "%s" Format Identifier'), + gdriveError) + if config.config_ldap_user_object.count("(") != config.config_ldap_user_object.count(")"): + return _configuration_result(_('LDAP User Object Filter Has Unmatched Parenthesis'), gdriveError) - if config.config_ldap_cert_path and not os.path.isfile(config.config_ldap_cert_path): - return _configuration_result('LDAP Certfile location is not valid, please enter correct path', gdriveError) + if config.config_ldap_cert_path and not os.path.isdir(config.config_ldap_cert_path): + return _configuration_result(_('LDAP Certificate Location is not Valid, Please Enter Correct Path'), + gdriveError) # Remote login configuration _config_checkbox("config_remote_login") @@ -628,12 +640,12 @@ def _configuration_update_helper(): reboot_required |= _config_int("config_log_level") reboot_required |= _config_string("config_logfile") if not logger.is_valid_logfile(config.config_logfile): - return _configuration_result('Logfile location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('Logfile Location is not Valid, Please Enter Correct Path'), gdriveError) reboot_required |= _config_checkbox_int("config_access_log") reboot_required |= _config_string("config_access_logfile") if not logger.is_valid_logfile(config.config_access_logfile): - return _configuration_result('Access Logfile location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('Access Logfile Location is not Valid, Please Enter Correct Path'), gdriveError) # Rarfile Content configuration _config_string("config_rarfile_location") @@ -652,7 +664,7 @@ def _configuration_update_helper(): if db_change: # reload(db) if not db.setup_db(config): - return _configuration_result('DB location is not valid, please enter correct path', gdriveError) + return _configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdriveError) config.save() flash(_(u"Calibre-Web configuration updated"), category="success") @@ -678,7 +690,7 @@ def _configuration_result(error_flash=None, gdriveError=None): show_login_button = config.db_configured and not current_user.is_authenticated if error_flash: config.load() - flash(_(error_flash), category="error") + flash(error_flash, category="error") show_login_button = False return render_title_template("config_edit.html", config=config, provider=oauthblueprints, diff --git a/cps/config_sql.py b/cps/config_sql.py index 98b30416..7fc99a91 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -73,7 +73,7 @@ class _Settings(_Base): config_kobo_sync = Column(Boolean, default=False) config_default_role = Column(SmallInteger, default=0) - config_default_show = Column(SmallInteger, default=6143) + config_default_show = Column(SmallInteger, default=constants.ADMIN_USER_SIDEBAR) config_columns_to_ignore = Column(String) config_denied_tags = Column(String, default="") @@ -99,11 +99,11 @@ class _Settings(_Base): config_ldap_port = Column(SmallInteger, default=389) # config_ldap_schema = Column(String, default='ldap') config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org') - config_ldap_serv_password = Column(String) + config_ldap_serv_password = Column(String, default="") config_ldap_encryption = Column(SmallInteger, default=0) # config_ldap_use_tls = Column(Boolean, default=False) # config_ldap_require_cert = Column(Boolean, default=False) - config_ldap_cert_path = Column(String) + config_ldap_cert_path = Column(String, default="") config_ldap_dn = Column(String, default='dc=example,dc=org') config_ldap_user_object = Column(String, default='uid=%s') config_ldap_openldap = Column(Boolean, default=True) @@ -285,7 +285,9 @@ class _ConfigSQL(object): self._session.commit() self.load() - def invalidate(self): + def invalidate(self, error=None): + if error: + log.error(error) log.warning("invalidating configuration") self.db_configured = False self.config_calibre_dir = None diff --git a/cps/db.py b/cps/db.py index f40e0cda..966adbe5 100755 --- a/cps/db.py +++ b/cps/db.py @@ -344,14 +344,13 @@ def setup_db(config): isolation_level="SERIALIZABLE", connect_args={'check_same_thread': False}) conn = engine.connect() - except: - config.invalidate() + # conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302 + except Exception as e: + config.invalidate(e) return False config.db_configured = True update_title_sort(config, conn.connection) - # conn.connection.create_function('lower', 1, lcase) - # conn.connection.create_function('upper', 1, ucase) if not cc_classes: cc = conn.execute("SELECT id, datatype FROM custom_columns") diff --git a/cps/services/__init__.py b/cps/services/__init__.py index 2eb82f0d..11ef4c65 100644 --- a/cps/services/__init__.py +++ b/cps/services/__init__.py @@ -30,10 +30,13 @@ except ImportError as err: goodreads_support = None -try: from . import simpleldap as ldap +try: + from . import simpleldap as ldap + from .simpleldap import ldapVersion except ImportError as err: log.debug("cannot import simpleldap, logging in with ldap will not work: %s", err) ldap = None + ldapVersion = None try: from . import SyncToken as SyncToken diff --git a/cps/services/simpleldap.py b/cps/services/simpleldap.py index 82690d87..841f61e1 100644 --- a/cps/services/simpleldap.py +++ b/cps/services/simpleldap.py @@ -23,6 +23,10 @@ from flask_simpleldap import LDAP, LDAPException from .. import constants, logger +try: + from ldap.pkginfo import __version__ as ldapVersion +except ImportError: + pass log = logger.create() _ldap = LDAP() @@ -34,14 +38,16 @@ def init_app(app, config): app.config['LDAP_HOST'] = config.config_ldap_provider_url app.config['LDAP_PORT'] = config.config_ldap_port - if config.config_ldap_encryption: + if config.config_ldap_encryption == 2: app.config['LDAP_SCHEMA'] = 'ldaps' else: app.config['LDAP_SCHEMA'] = 'ldap' # app.config['LDAP_SCHEMA'] = config.config_ldap_schema app.config['LDAP_USERNAME'] = config.config_ldap_serv_username + if config.config_ldap_serv_password is None: + config.config_ldap_serv_password = '' app.config['LDAP_PASSWORD'] = base64.b64decode(config.config_ldap_serv_password) - if config.config_ldap_cert_path: + if bool(config.config_ldap_cert_path): app.config['LDAP_REQUIRE_CERT'] = True app.config['LDAP_CERT_PATH'] = config.config_ldap_cert_path app.config['LDAP_BASE_DN'] = config.config_ldap_dn @@ -52,6 +58,7 @@ def init_app(app, config): app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap) app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field + # app.config['LDAP_CUSTOM_OPTIONS'] = {'OPT_NETWORK_TIMEOUT': 10} _ldap.init_app(app) @@ -78,16 +85,22 @@ def bind_user(username, password): :returns: True if login succeeded, False if login failed, None if server unavailable. ''' try: - result = _ldap.bind_user(username, password) - log.debug("LDAP login '%s': %r", username, result) - return result is not None + if _ldap.get_object_details(username): + result = _ldap.bind_user(username, password) + log.debug("LDAP login '%s': %r", username, result) + return result is not None, None + return None, None # User not found + except (TypeError, AttributeError) as ex: + error = ("LDAP bind_user: %s" % ex) + return None, error except LDAPException as ex: if ex.message == 'Invalid credentials': - log.info("LDAP login '%s' failed: %s", username, ex) - return False + error = ("LDAP admin login failed") + return None, error if ex.message == "Can't contact LDAP server": - log.warning('LDAP Server down: %s', ex) - return None + # log.warning('LDAP Server down: %s', ex) + error = ('LDAP Server down: %s' % ex) + return None, error else: - log.warning('LDAP Server error: %s', ex.message) - return None + error = ('LDAP Server error: %s' % ex.message) + return None, error diff --git a/cps/templates/admin.html b/cps/templates/admin.html index b548f021..ab143c59 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -35,9 +35,8 @@ {% endif %} {% endfor %} - {% if not (config.config_login_type == 1) %}
{{_('Add New User')}}
- {% else %} + {% if (config.config_login_type == 1) %}
{{_('Import LDAP Users')}}
{% endif %} diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 5b114739..e9abdfde 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -230,18 +230,11 @@
- +
- - -
-
- - -
-
- + +
+ +
+ + +
+
+ + +
@@ -266,18 +268,19 @@
-
- - -
-
- - -
-
- - -
+

{{_('Following Settings are Needed For User Import')}}

+
+ + +
+
+ + +
+
+ + +
{% endif %} {% if feature_support['oauth'] %} diff --git a/cps/ub.py b/cps/ub.py index 21dc6e3c..13e996cc 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -232,7 +232,7 @@ class Anonymous(AnonymousUserMixin, UserBase): self.sidebar_view = data.sidebar_view self.default_language = data.default_language self.locale = data.locale - self.mature_content = data.mature_content + # self.mature_content = data.mature_content self.kindle_mail = data.kindle_mail self.denied_tags = data.denied_tags self.allowed_tags = data.allowed_tags @@ -441,14 +441,12 @@ def migrate_Database(session): "locale VARCHAR(2)," "sidebar_view INTEGER," "default_language VARCHAR(3)," - "mature_content BOOLEAN," "UNIQUE (nickname)," - "UNIQUE (email)," - "CHECK (mature_content IN (0, 1)))") + "UNIQUE (email))") conn.execute("INSERT INTO user_id(id, nickname, email, role, password, kindle_mail,locale," - "sidebar_view, default_language, mature_content) " + "sidebar_view, default_language) " "SELECT id, nickname, email, role, password, kindle_mail, locale," - "sidebar_view, default_language, mature_content FROM user") + "sidebar_view, default_language FROM user") # delete old user table and rename new user_id table to user: conn.execute("DROP TABLE user") conn.execute("ALTER TABLE user_id RENAME TO user") diff --git a/cps/web.py b/cps/web.py index e85b7c0e..84831bc2 100644 --- a/cps/web.py +++ b/cps/web.py @@ -28,6 +28,7 @@ import json import mimetypes import traceback import binascii +import re from babel import Locale as LC from babel.dates import format_date @@ -278,31 +279,66 @@ def import_ldap_users(): showtext = {} try: new_users = services.ldap.get_group_members(config.config_ldap_group_name) - except services.ldap.LDAPException as e: + except (services.ldap.LDAPException, TypeError, AttributeError) as e: log.debug(e) - showtext['text'] = _(u'Error : %(ldaperror)s', ldaperror=e) + showtext['text'] = _(u'Error: %(ldaperror)s', ldaperror=e) + return json.dumps(showtext) + if not new_users: + log.debug('LDAP empty response') + showtext['text'] = _(u'Error: No user returned in response of LDAP server') return json.dumps(showtext) for username in new_users: - user_data = services.ldap.get_object_details(user=username, group=None, query_filter=None, dn_only=False) - content = ub.User() - content.nickname = username - content.password = username # dummy password which will be replaced by ldap one - content.email = user_data['mail'][0] - if (len(user_data['mail']) > 1): - content.kindle_mail = user_data['mail'][1] - content.role = config.config_default_role - content.sidebar_view = config.config_default_show - content.mature_content = bool(config.config_default_show & constants.MATURE_CONTENT) - ub.session.add(content) - try: - ub.session.commit() - except Exception as e: - log.warning("Failed to create LDAP user: %s - %s", username, e) - ub.session.rollback() - showtext['text'] = _(u'Failed to create at least one LDAP user') + user = username.decode('utf-8') + if '=' in user: + match = re.search("([a-zA-Z0-9-]+)=%s", config.config_ldap_user_object, re.IGNORECASE | re.UNICODE) + if match: + match_filter = match.group(1) + match = re.search(match_filter + "=([[\d\w-]+)", user, re.IGNORECASE | re.UNICODE) + if match: + user = match.group(1) + else: + log.warning("Could Not Parse LDAP User: %s", user) + continue + else: + log.warning("Could Not Parse LDAP User: %s", user) + continue + if ub.session.query(ub.User).filter(ub.User.nickname == user.lower()).first(): + log.warning("LDAP User: %s Already in Database", user) + continue + user_data = services.ldap.get_object_details(user=user, + group=None, + query_filter=None, + dn_only=False) + if user_data: + content = ub.User() + content.nickname = user + content.password = '' # dummy password which will be replaced by ldap one + if 'mail' in user_data: + content.email = user_data['mail'][0].decode('utf-8') + if (len(user_data['mail']) > 1): + content.kindle_mail = user_data['mail'][1].decode('utf-8') + else: + log.debug('No Mail Field Found in LDAP Response') + content.email = user + '@email.com' + content.role = config.config_default_role + content.sidebar_view = config.config_default_show + 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) + try: + ub.session.commit() + except Exception as e: + log.warning("Failed to create LDAP user: %s - %s", user, e) + ub.session.rollback() + showtext['text'] = _(u'Failed to Create at Least One LDAP User') + else: + log.warning("LDAP User: %s Not Found", user) + showtext['text'] = _(u'At Least One LDAP User Not Found in Database') if not showtext: - showtext['text'] = _(u'User successfully imported') + showtext['text'] = _(u'User Successfully Imported') return json.dumps(showtext) @@ -844,8 +880,9 @@ def reconnect(): @web.route("/search", methods=["GET"]) @login_required_if_no_ano def search(): - term = request.args.get("query").strip().lower() + term = request.args.get("query") if term: + term.strip().lower() entries = get_search_results(term) ids = list() for element in entries: @@ -1175,24 +1212,27 @@ def login(): form = request.form.to_dict() user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == form['username'].strip().lower())\ .first() - if config.config_login_type == constants.LOGIN_LDAP and services.ldap and user: - login_result = services.ldap.bind_user(form['username'], form['password']) + if config.config_login_type == constants.LOGIN_LDAP and services.ldap and user and form['password'] != "": + login_result, error = services.ldap.bind_user(form['username'], form['password']) + # None if credentials are not matching + # -1 if LDAP Server error + # 0 if wrong passwort if login_result: login_user(user, remember=True) log.debug(u"You are now logged in as: '%s'", user.nickname) flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") return redirect_back(url_for("web.index")) - elif user and check_password_hash(str(user.password), form['password']) and user.nickname != "Guest": + elif login_result is None and user and check_password_hash(str(user.password), form['password']) and user.nickname != "Guest": login_user(user, remember=True) - log.info("LDAP Server Down, Fallback Login as: %(nickname)s", user.nickname) - flash(_(u"LDAP Server Down, Fallback Login as: '%(nickname)s'", + log.info("Local Fallback Login as: '%s'", user.nickname) + flash(_(u"Fallback Login as: '%(nickname)s', LDAP Server not reachable, or user not known", nickname=user.nickname), category="warning") return redirect_back(url_for("web.index")) elif login_result is None: - log.info("Could not login. LDAP server down") - flash(_(u"Could not login. LDAP server down, please contact your administrator"), category="error") + log.info(error) + flash(_(u"Could not login: %(message)s", message=error), category="error") else: ipAdress = request.headers.get('X-Forwarded-For', request.remote_addr) log.info('LDAP Login failed for user "%s" IP-adress: %s', form['username'], ipAdress) diff --git a/test/Calibre-Web TestSummary.html b/test/Calibre-Web TestSummary.html old mode 100644 new mode 100755 index d0c8094f..cb9772bf --- a/test/Calibre-Web TestSummary.html +++ b/test/Calibre-Web TestSummary.html @@ -36,17 +36,17 @@
-

Start Time: 2020-04-02 20:21:13

+

Start Time: 2020-04-13 20:58:27

-

Stop Time: 2020-04-02 20:57:57

+

Stop Time: 2020-04-13 21:36:17

-

Duration: 1878.33 s

+

Duration: 2002.47 s

@@ -301,8 +301,8 @@ test_ebook_convert.test_ebook_convert 11 - 11 - 0 + 7 + 4 0 0 @@ -339,11 +339,33 @@ - +
test_convert_email
- PASS + +
+ FAIL +
+ + + + @@ -357,20 +379,64 @@ - +
test_convert_only
- PASS + +
+ FAIL +
+ + + + - +
test_convert_parameter
- PASS + +
+ FAIL +
+ + + + @@ -393,11 +459,33 @@ - +
test_email_only
- PASS + +
+ FAIL +
+ + + + @@ -414,13 +502,13 @@ test_edit_books.test_edit_books - 2 + 30 + 27 0 0 - 0 - 2 + 3 - Detail + Detail @@ -452,19 +540,109 @@ - + -
test_rename_uppercase_lowercase
+
test_download_book
+ + PASS + + + + + + +
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 + SKIP
-