diff --git a/cps/about.py b/cps/about.py index f9c58738..6ea584f4 100644 --- a/cps/about.py +++ b/cps/about.py @@ -31,7 +31,7 @@ import werkzeug, flask, flask_login, flask_principal, jinja2 from flask_babel import gettext as _ from . import db, calibre_db, converter, uploader, server, isoLanguages, constants -from .web import render_title_template +from .render_template import render_title_template try: from flask_login import __version__ as flask_loginVersion except ImportError: diff --git a/cps/admin.py b/cps/admin.py index de84a5d9..2012730b 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -31,20 +31,25 @@ from datetime import datetime, timedelta from babel import Locale as LC from babel.dates import format_datetime -from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory -from flask_login import login_required, current_user, logout_user +from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g +from flask_login import login_required, current_user, logout_user, confirm_login from flask_babel import gettext as _ from sqlalchemy import and_ from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError -from sqlalchemy.sql.expression import func +from sqlalchemy.sql.expression import func, or_ from . import constants, logger, helper, services from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash from .gdriveutils import is_gdrive_ready, gdrive_support -from .web import admin_required, render_title_template, before_request, unconfigured +from .render_template import render_title_template from . import debug_info +try: + from functools import wraps +except ImportError: + pass # We're not using Python 3 + log = logger.create() feature_support = { @@ -73,6 +78,49 @@ feature_support['gdrive'] = gdrive_support admi = Blueprint('admin', __name__) +def admin_required(f): + """ + Checks if current_user.role == 1 + """ + + @wraps(f) + def inner(*args, **kwargs): + if current_user.role_admin(): + return f(*args, **kwargs) + abort(403) + + return inner + + +def unconfigured(f): + """ + Checks if calibre-web instance is not configured + """ + @wraps(f) + def inner(*args, **kwargs): + if not config.db_configured: + return f(*args, **kwargs) + abort(403) + + return inner + +@admi.before_app_request +def before_request(): + if current_user.is_authenticated: + confirm_login() + g.constants = constants + g.user = current_user + g.allow_registration = config.config_public_reg + g.allow_anonymous = config.config_anonbrowse + g.allow_upload = config.config_uploading + g.current_theme = config.config_theme + g.config_authors_max = config.config_authors_max + g.shelves_access = ub.session.query(ub.Shelf).filter( + or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all() + if not config.db_configured and request.endpoint not in ( + 'admin.basic_configuration', 'login') and '/static/' not in request.path: + return redirect(url_for('admin.basic_configuration')) + @admi.route("/admin") @login_required @@ -1269,3 +1317,110 @@ def get_updater_status(): except Exception: status['status'] = 11 return json.dumps(status) + + +@admi.route('/import_ldap_users') +@login_required +@admin_required +def import_ldap_users(): + showtext = {} + try: + new_users = services.ldap.get_group_members(config.config_ldap_group_name) + except (services.ldap.LDAPException, TypeError, AttributeError, KeyError) as e: + log.exception(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) + + imported = 0 + for username in new_users: + user = username.decode('utf-8') + if '=' in user: + # if member object field is empty take user object as filter + if config.config_ldap_member_user_object: + query_filter = config.config_ldap_member_user_object + else: + query_filter = config.config_ldap_user_object + try: + user_identifier = extract_user_identifier(user, query_filter) + except Exception as e: + log.warning(e) + continue + else: + user_identifier = user + query_filter = None + try: + user_data = services.ldap.get_object_details(user=user_identifier, query_filter=query_filter) + except AttributeError as e: + log.exception(e) + continue + if user_data: + user_login_field = extract_dynamic_field_from_filter(user, config.config_ldap_user_object) + + username = user_data[user_login_field][0].decode('utf-8') + # check for duplicate username + if ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == username.lower()).first(): + # if ub.session.query(ub.User).filter(ub.User.nickname == username).first(): + log.warning("LDAP User %s Already in Database", user_data) + continue + + kindlemail = '' + if 'mail' in user_data: + useremail = user_data['mail'][0].decode('utf-8') + if (len(user_data['mail']) > 1): + kindlemail = user_data['mail'][1].decode('utf-8') + + else: + log.debug('No Mail Field Found in LDAP Response') + useremail = username + '@email.com' + # check for duplicate email + if ub.session.query(ub.User).filter(func.lower(ub.User.email) == useremail.lower()).first(): + log.warning("LDAP Email %s Already in Database", user_data) + continue + content = ub.User() + content.nickname = username + content.password = '' # dummy password which will be replaced by ldap one + content.email = useremail + content.kindle_mail = kindlemail + 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() + imported +=1 + 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'.format(imported)) + return json.dumps(showtext) + + +def extract_user_data_from_field(user, field): + match = re.search(field + "=([\d\s\w-]+)", user, re.IGNORECASE | re.UNICODE) + if match: + return match.group(1) + else: + raise Exception("Could Not Parse LDAP User: {}".format(user)) + +def extract_dynamic_field_from_filter(user, filter): + match = re.search("([a-zA-Z0-9-]+)=%s", filter, re.IGNORECASE | re.UNICODE) + if match: + return match.group(1) + else: + raise Exception("Could Not Parse LDAP Userfield: {}", user) + +def extract_user_identifier(user, filter): + dynamic_field = extract_dynamic_field_from_filter(user, filter) + return extract_user_data_from_field(user, dynamic_field) diff --git a/cps/editbooks.py b/cps/editbooks.py index e62986c8..08ee93b1 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -37,13 +37,38 @@ from . import config, get_locale, ub, db from . import calibre_db from .services.worker import WorkerThread from .tasks.upload import TaskUpload -from .web import login_required_if_no_ano, render_title_template, edit_required, upload_required +from .render_template import render_title_template +from .usermanagement import login_required_if_no_ano + +try: + from functools import wraps +except ImportError: + pass # We're not using Python 3 editbook = Blueprint('editbook', __name__) log = logger.create() +def upload_required(f): + @wraps(f) + def inner(*args, **kwargs): + if current_user.role_upload() or current_user.role_admin(): + return f(*args, **kwargs) + abort(403) + + return inner + +def edit_required(f): + @wraps(f) + def inner(*args, **kwargs): + if current_user.role_edit() or current_user.role_admin(): + return f(*args, **kwargs) + abort(403) + + return inner + + # Modifies different Database objects, first check if elements have to be added to database, than check # if elements have to be deleted, because they are no longer used def modify_database_object(input_elements, db_book_object, db_object, db_session, db_type): diff --git a/cps/error_handler.py b/cps/error_handler.py new file mode 100644 index 00000000..e2e1638f --- /dev/null +++ b/cps/error_handler.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2020 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 traceback +from flask import render_template +from werkzeug.exceptions import default_exceptions +try: + from werkzeug.exceptions import FailedDependency +except ImportError: + from werkzeug.exceptions import UnprocessableEntity as FailedDependency + +from . import config, app, logger, services + + +log = logger.create() + +# custom error page +def error_http(error): + return render_template('http_error.html', + error_code="Error {0}".format(error.code), + error_name=error.name, + issue=False, + instance=config.config_calibre_web_title + ), error.code + + +def internal_error(error): + return render_template('http_error.html', + error_code="Internal Server Error", + error_name=str(error), + issue=True, + error_stack=traceback.format_exc().split("\n"), + instance=config.config_calibre_web_title + ), 500 + +# http error handling +for ex in default_exceptions: + if ex < 500: + app.register_error_handler(ex, error_http) + elif ex == 500: + app.register_error_handler(ex, internal_error) + + +if services.ldap: + # Only way of catching the LDAPException upon logging in with LDAP server down + @app.errorhandler(services.ldap.LDAPException) + def handle_exception(e): + log.debug('LDAP server not accessible while trying to login to opds feed') + return error_http(FailedDependency()) + + +# @app.errorhandler(InvalidRequestError) +#@app.errorhandler(OperationalError) +#def handle_db_exception(e): +# db.session.rollback() +# log.error('Database request error: %s',e) +# return internal_error(InternalServerError(e)) diff --git a/cps/gdrive.py b/cps/gdrive.py index 9ba87260..b1896bbb 100644 --- a/cps/gdrive.py +++ b/cps/gdrive.py @@ -35,7 +35,7 @@ from flask_babel import gettext as _ from flask_login import login_required from . import logger, gdriveutils, config, ub, calibre_db -from .web import admin_required +from .admin import admin_required gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive') log = logger.create() diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py index 85d7b7ac..3288bb73 100644 --- a/cps/kobo_auth.py +++ b/cps/kobo_auth.py @@ -69,7 +69,7 @@ from flask_babel import gettext as _ from sqlalchemy.exc import OperationalError from . import logger, ub, lm -from .web import render_title_template +from .render_template import render_title_template try: from functools import wraps diff --git a/cps/oauth_bb.py b/cps/oauth_bb.py index ba09678d..cafef3eb 100644 --- a/cps/oauth_bb.py +++ b/cps/oauth_bb.py @@ -30,12 +30,12 @@ from flask_babel import gettext as _ from flask_dance.consumer import oauth_authorized, oauth_error from flask_dance.contrib.github import make_github_blueprint, github from flask_dance.contrib.google import make_google_blueprint, google -from flask_login import login_user, current_user +from flask_login import login_user, current_user, login_required from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.exc import OperationalError from . import constants, logger, config, app, ub -from .web import login_required + from .oauth import OAuthBackend, backend_resultcode diff --git a/cps/opds.py b/cps/opds.py index 4074fe7d..7689eacd 100644 --- a/cps/opds.py +++ b/cps/opds.py @@ -28,12 +28,13 @@ from functools import wraps from flask import Blueprint, request, render_template, Response, g, make_response, abort from flask_login import current_user from sqlalchemy.sql.expression import func, text, or_, and_ -from werkzeug.security import check_password_hash + from . import constants, logger, config, db, calibre_db, ub, services, get_locale, isoLanguages from .helper import get_download_link, get_book_cover from .pagination import Pagination -from .web import render_read_books, load_user_from_request +from .web import render_read_books +from .usermanagement import load_user_from_request from flask_babel import gettext as _ from babel import Locale as LC from babel.core import UnknownLocaleError diff --git a/cps/remotelogin.py b/cps/remotelogin.py new file mode 100644 index 00000000..39a1d8e6 --- /dev/null +++ b/cps/remotelogin.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11, +# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh, +# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe, +# ruben-herold, marblepebble, JackED42, SiphonSquirrel, +# apetresc, nanu-c, mutschler +# +# 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 json +from datetime import datetime + +from flask import Blueprint, request, make_response, abort, url_for, flash, redirect +from flask_login import login_required, current_user, login_user +from flask_babel import gettext as _ + +from . import config, logger, ub +from .render_template import render_title_template + +try: + from functools import wraps +except ImportError: + pass # We're not using Python 3 + +remotelogin = Blueprint('remotelogin', __name__) +log = logger.create() + + +def remote_login_required(f): + @wraps(f) + def inner(*args, **kwargs): + if config.config_remote_login: + return f(*args, **kwargs) + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + data = {'status': 'error', 'message': 'Forbidden'} + response = make_response(json.dumps(data, ensure_ascii=False)) + response.headers["Content-Type"] = "application/json; charset=utf-8" + return response, 403 + abort(403) + + return inner + +@remotelogin.route('/remote/login') +@remote_login_required +def remote_login(): + auth_token = ub.RemoteAuthToken() + ub.session.add(auth_token) + ub.session.commit() + + verify_url = url_for('web.verify_token', token=auth_token.auth_token, _external=true) + log.debug(u"Remot Login request with token: %s", auth_token.auth_token) + return render_title_template('remote_login.html', title=_(u"login"), token=auth_token.auth_token, + verify_url=verify_url, page="remotelogin") + + +@remotelogin.route('/verify/') +@remote_login_required +@login_required +def verify_token(token): + auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first() + + # Token not found + if auth_token is None: + flash(_(u"Token not found"), category="error") + log.error(u"Remote Login token not found") + return redirect(url_for('web.index')) + + # Token expired + if datetime.now() > auth_token.expiration: + ub.session.delete(auth_token) + ub.session.commit() + + flash(_(u"Token has expired"), category="error") + log.error(u"Remote Login token expired") + return redirect(url_for('web.index')) + + # Update token with user information + auth_token.user_id = current_user.id + auth_token.verified = True + ub.session.commit() + + flash(_(u"Success! Please return to your device"), category="success") + log.debug(u"Remote Login token for userid %s verified", auth_token.user_id) + return redirect(url_for('web.index')) + + +@remotelogin.route('/ajax/verify_token', methods=['POST']) +@remote_login_required +def token_verified(): + token = request.form['token'] + auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first() + + data = {} + + # Token not found + if auth_token is None: + data['status'] = 'error' + data['message'] = _(u"Token not found") + + # Token expired + elif datetime.now() > auth_token.expiration: + ub.session.delete(auth_token) + ub.session.commit() + + data['status'] = 'error' + data['message'] = _(u"Token has expired") + + elif not auth_token.verified: + data['status'] = 'not_verified' + + else: + user = ub.session.query(ub.User).filter(ub.User.id == auth_token.user_id).first() + login_user(user) + + ub.session.delete(auth_token) + ub.session.commit() + + data['status'] = 'success' + log.debug(u"Remote Login for userid %s succeded", user.id) + flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") + + response = make_response(json.dumps(data, ensure_ascii=False)) + response.headers["Content-Type"] = "application/json; charset=utf-8" + + return response diff --git a/cps/render_template.py b/cps/render_template.py new file mode 100644 index 00000000..c743407d --- /dev/null +++ b/cps/render_template.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2020 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 . + +from flask import render_template +from flask_babel import gettext as _ +from flask import g +from werkzeug.local import LocalProxy + +from . import config, constants +from .ub import User + + +def _get_sidebar_config(kwargs=None): + kwargs = kwargs or [] + if 'content' in kwargs: + content = kwargs['content'] + content = isinstance(content, (User, LocalProxy)) and not content.role_anonymous() + else: + content = 'conf' in kwargs + sidebar = list() + sidebar.append({"glyph": "glyphicon-book", "text": _('Recently Added'), "link": 'web.index', "id": "new", + "visibility": constants.SIDEBAR_RECENT, 'public': True, "page": "root", + "show_text": _('Show recent books'), "config_show":False}) + sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot", + "visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot", + "show_text": _('Show Hot Books'), "config_show": True}) + sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list', + "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), + "page": "download", "show_text": _('Show Downloaded Books'), + "config_show": content}) + sidebar.append( + {"glyph": "glyphicon-star", "text": _('Top Rated Books'), "link": 'web.books_list', "id": "rated", + "visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated", + "show_text": _('Show Top Rated Books'), "config_show": True}) + sidebar.append({"glyph": "glyphicon-eye-open", "text": _('Read Books'), "link": 'web.books_list', "id": "read", + "visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous), + "page": "read", "show_text": _('Show read and unread'), "config_show": content}) + sidebar.append( + {"glyph": "glyphicon-eye-close", "text": _('Unread Books'), "link": 'web.books_list', "id": "unread", + "visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous), "page": "unread", + "show_text": _('Show unread'), "config_show": False}) + sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand", + "visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover", + "show_text": _('Show random books'), "config_show": True}) + sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat", + "visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category", + "show_text": _('Show category selection'), "config_show": True}) + sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie", + "visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series", + "show_text": _('Show series selection'), "config_show": True}) + sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author", + "visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author", + "show_text": _('Show author selection'), "config_show": True}) + sidebar.append( + {"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher", + "visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher", + "show_text": _('Show publisher selection'), "config_show":True}) + sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang", + "visibility": constants.SIDEBAR_LANGUAGE, 'public': (g.user.filter_language() == 'all'), + "page": "language", + "show_text": _('Show language selection'), "config_show": True}) + sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate", + "visibility": constants.SIDEBAR_RATING, 'public': True, + "page": "rating", "show_text": _('Show ratings selection'), "config_show": True}) + sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format", + "visibility": constants.SIDEBAR_FORMAT, 'public': True, + "page": "format", "show_text": _('Show file formats selection'), "config_show": True}) + sidebar.append( + {"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived", + "visibility": constants.SIDEBAR_ARCHIVED, 'public': (not g.user.is_anonymous), "page": "archived", + "show_text": _('Show archived books'), "config_show": content}) + sidebar.append( + {"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list", + "visibility": constants.SIDEBAR_LIST, 'public': (not g.user.is_anonymous), "page": "list", + "show_text": _('Show Books List'), "config_show": content}) + + return sidebar + +# Returns the template for rendering and includes the instance name +def render_title_template(*args, **kwargs): + sidebar = _get_sidebar_config(kwargs) + return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, + accept=constants.EXTENSIONS_UPLOAD, + *args, **kwargs) diff --git a/cps/shelf.py b/cps/shelf.py index e7dcc4e7..645640c4 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -30,7 +30,8 @@ from sqlalchemy.sql.expression import func from sqlalchemy.exc import OperationalError, InvalidRequestError from . import logger, ub, calibre_db -from .web import login_required_if_no_ano, render_title_template +from .render_template import render_title_template +from .usermanagement import login_required_if_no_ano shelf = Blueprint('shelf', __name__) diff --git a/cps/templates/shelf.html b/cps/templates/shelf.html index 425e298c..75e369ed 100644 --- a/cps/templates/shelf.html +++ b/cps/templates/shelf.html @@ -2,18 +2,33 @@ {% block body %}

{{title}}

- {% if g.user.role_download() %} - {{ _('Download') }} + {% if g.user.role_download() %} + {{ _('Download') }} {% endif %} {% if g.user.is_authenticated %} {% if (g.user.role_edit_shelfs() and shelf.is_public ) or not shelf.is_public %}
{{ _('Delete this Shelf') }}
{{ _('Edit Shelf') }} - {{ _('Change order') }} + {% if entries.__len__() %} + {{ _('Change order') }} + {% endif %} {% endif %} {% endif %} +
- {% for entry in entries %}
diff --git a/cps/ub.py b/cps/ub.py index 68e866fb..dbc3b419 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -26,10 +26,8 @@ import uuid from flask import session as flask_session from binascii import hexlify -from flask import g -from flask_babel import gettext as _ from flask_login import AnonymousUserMixin, current_user -from werkzeug.local import LocalProxy + try: from flask_dance.consumer.backend.sqla import OAuthConsumerMixin oauth_support = True @@ -57,73 +55,6 @@ Base = declarative_base() searched_ids = {} -def get_sidebar_config(kwargs=None): - kwargs = kwargs or [] - if 'content' in kwargs: - content = kwargs['content'] - content = isinstance(content, (User, LocalProxy)) and not content.role_anonymous() - else: - content = 'conf' in kwargs - sidebar = list() - sidebar.append({"glyph": "glyphicon-book", "text": _('Recently Added'), "link": 'web.index', "id": "new", - "visibility": constants.SIDEBAR_RECENT, 'public': True, "page": "root", - "show_text": _('Show recent books'), "config_show":False}) - sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot", - "visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot", - "show_text": _('Show Hot Books'), "config_show": True}) - sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list', - "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), - "page": "download", "show_text": _('Show Downloaded Books'), - "config_show": content}) - sidebar.append( - {"glyph": "glyphicon-star", "text": _('Top Rated Books'), "link": 'web.books_list', "id": "rated", - "visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated", - "show_text": _('Show Top Rated Books'), "config_show": True}) - sidebar.append({"glyph": "glyphicon-eye-open", "text": _('Read Books'), "link": 'web.books_list', "id": "read", - "visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous), - "page": "read", "show_text": _('Show read and unread'), "config_show": content}) - sidebar.append( - {"glyph": "glyphicon-eye-close", "text": _('Unread Books'), "link": 'web.books_list', "id": "unread", - "visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous), "page": "unread", - "show_text": _('Show unread'), "config_show": False}) - sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand", - "visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover", - "show_text": _('Show random books'), "config_show": True}) - sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat", - "visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category", - "show_text": _('Show category selection'), "config_show": True}) - sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie", - "visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series", - "show_text": _('Show series selection'), "config_show": True}) - sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author", - "visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author", - "show_text": _('Show author selection'), "config_show": True}) - sidebar.append( - {"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher", - "visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher", - "show_text": _('Show publisher selection'), "config_show":True}) - sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang", - "visibility": constants.SIDEBAR_LANGUAGE, 'public': (g.user.filter_language() == 'all'), - "page": "language", - "show_text": _('Show language selection'), "config_show": True}) - sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate", - "visibility": constants.SIDEBAR_RATING, 'public': True, - "page": "rating", "show_text": _('Show ratings selection'), "config_show": True}) - sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format", - "visibility": constants.SIDEBAR_FORMAT, 'public': True, - "page": "format", "show_text": _('Show file formats selection'), "config_show": True}) - sidebar.append( - {"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived", - "visibility": constants.SIDEBAR_ARCHIVED, 'public': (not g.user.is_anonymous), "page": "archived", - "show_text": _('Show archived books'), "config_show": content}) - sidebar.append( - {"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list", - "visibility": constants.SIDEBAR_LIST, 'public': (not g.user.is_anonymous), "page": "list", - "show_text": _('Show Books List'), "config_show": content}) - - return sidebar - - def store_ids(result): ids = list() for element in result: diff --git a/cps/usermanagement.py b/cps/usermanagement.py new file mode 100644 index 00000000..3a00f34d --- /dev/null +++ b/cps/usermanagement.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2018-2020 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 base64 +import binascii + +from sqlalchemy.sql.expression import func +from werkzeug.security import check_password_hash +from flask_login import login_required + +from . import lm, ub, config, constants, services + +try: + from functools import wraps +except ImportError: + pass # We're not using Python 3 + +def login_required_if_no_ano(func): + @wraps(func) + def decorated_view(*args, **kwargs): + if config.config_anonbrowse == 1: + return func(*args, **kwargs) + return login_required(func)(*args, **kwargs) + + return decorated_view + + +def _fetch_user_by_name(username): + return ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == username.lower()).first() + + +@lm.user_loader +def load_user(user_id): + return ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() + + +@lm.request_loader +def load_user_from_request(request): + if config.config_allow_reverse_proxy_header_login: + rp_header_name = config.config_reverse_proxy_login_header_name + if rp_header_name: + rp_header_username = request.headers.get(rp_header_name) + if rp_header_username: + user = _fetch_user_by_name(rp_header_username) + if user: + return user + + auth_header = request.headers.get("Authorization") + if auth_header: + user = load_user_from_auth_header(auth_header) + if user: + return user + + return + + +def load_user_from_auth_header(header_val): + if header_val.startswith('Basic '): + header_val = header_val.replace('Basic ', '', 1) + basic_username = basic_password = '' + try: + header_val = base64.b64decode(header_val).decode('utf-8') + basic_username = header_val.split(':')[0] + basic_password = header_val.split(':')[1] + except (TypeError, UnicodeDecodeError, binascii.Error): + pass + user = _fetch_user_by_name(basic_username) + if user and config.config_login_type == constants.LOGIN_LDAP and services.ldap: + if services.ldap.bind_user(str(user.password), basic_password): + return user + if user and check_password_hash(str(user.password), basic_password): + return user + return diff --git a/cps/web.py b/cps/web.py index 9715a90e..ec87089f 100644 --- a/cps/web.py +++ b/cps/web.py @@ -22,47 +22,40 @@ from __future__ import division, print_function, unicode_literals import os -import base64 from datetime import datetime import json import mimetypes -import traceback -import binascii -import re import chardet # dependency of requests from babel.dates import format_date from babel import Locale as LC from babel.core import UnknownLocaleError from flask import Blueprint, jsonify -from flask import render_template, request, redirect, send_from_directory, make_response, g, flash, abort, url_for -from flask import session as flask_session, send_file +from flask import request, redirect, send_from_directory, make_response, flash, abort, url_for +from flask import session as flask_session from flask_babel import gettext as _ -from flask_login import login_user, logout_user, login_required, current_user, confirm_login +from flask_login import login_user, logout_user, login_required, current_user from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError -from sqlalchemy.sql.expression import text, func, true, false, not_, and_, or_ +from sqlalchemy.sql.expression import text, func, false, not_, and_ from sqlalchemy.orm.attributes import flag_modified -from werkzeug.exceptions import default_exceptions from sqlalchemy.sql.functions import coalesce from .services.worker import WorkerThread -try: - from werkzeug.exceptions import FailedDependency -except ImportError: - from werkzeug.exceptions import UnprocessableEntity as FailedDependency from werkzeug.datastructures import Headers from werkzeug.security import generate_password_hash, check_password_hash from . import constants, logger, isoLanguages, services -from . import lm, babel, db, ub, config, get_locale, app -from . import calibre_db +from . import babel, db, ub, config, get_locale, app +from . import calibre_db, shelf from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download from .helper import check_valid_domain, render_task_status, \ get_cc_columns, get_book_cover, get_download_link, send_mail, generate_random_password, \ send_registration_mail, check_send_to_kindle, check_read_formats, tags_filters, reset_password from .pagination import Pagination from .redirect import redirect_back +from .usermanagement import login_required_if_no_ano +from .render_template import render_title_template feature_support = { 'ldap': bool(services.ldap), @@ -72,7 +65,6 @@ feature_support = { try: from .oauth_bb import oauth_check, register_user_with_oauth, logout_oauth_user, get_oauth_status - feature_support['oauth'] = True except ImportError: feature_support['oauth'] = False @@ -83,55 +75,12 @@ try: except ImportError: pass # We're not using Python 3 - try: from natsort import natsorted as sort except ImportError: sort = sorted # Just use regular sort then, may cause issues with badly named pages in cbz/cbr files -# custom error page -def error_http(error): - return render_template('http_error.html', - error_code="Error {0}".format(error.code), - error_name=error.name, - issue=False, - instance=config.config_calibre_web_title - ), error.code - - -def internal_error(error): - return render_template('http_error.html', - error_code="Internal Server Error", - error_name=str(error), - issue=True, - error_stack=traceback.format_exc().split("\n"), - instance=config.config_calibre_web_title - ), 500 - - -# http error handling -for ex in default_exceptions: - if ex < 500: - app.register_error_handler(ex, error_http) - elif ex == 500: - app.register_error_handler(ex, internal_error) - - -if feature_support['ldap']: - # Only way of catching the LDAPException upon logging in with LDAP server down - @app.errorhandler(services.ldap.LDAPException) - def handle_exception(e): - log.debug('LDAP server not accessible while trying to login to opds feed') - return error_http(FailedDependency()) - -# @app.errorhandler(InvalidRequestError) -#@app.errorhandler(OperationalError) -#def handle_db_exception(e): -# db.session.rollback() -# log.error('Database request error: %s',e) -# return internal_error(InternalServerError(e)) - @app.after_request def add_security_headers(resp): # resp.headers['Content-Security-Policy']= "script-src 'self' https://www.googleapis.com https://api.douban.com https://comicvine.gamespot.com;" @@ -147,104 +96,6 @@ log = logger.create() # ################################### Login logic and rights management ############################################### -def _fetch_user_by_name(username): - return ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == username.lower()).first() - - -@lm.user_loader -def load_user(user_id): - return ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() - - -@lm.request_loader -def load_user_from_request(request): - if config.config_allow_reverse_proxy_header_login: - rp_header_name = config.config_reverse_proxy_login_header_name - if rp_header_name: - rp_header_username = request.headers.get(rp_header_name) - if rp_header_username: - user = _fetch_user_by_name(rp_header_username) - if user: - return user - - auth_header = request.headers.get("Authorization") - if auth_header: - user = load_user_from_auth_header(auth_header) - if user: - return user - - return - - -def load_user_from_auth_header(header_val): - if header_val.startswith('Basic '): - header_val = header_val.replace('Basic ', '', 1) - basic_username = basic_password = '' - try: - header_val = base64.b64decode(header_val).decode('utf-8') - basic_username = header_val.split(':')[0] - basic_password = header_val.split(':')[1] - except (TypeError, UnicodeDecodeError, binascii.Error): - pass - user = _fetch_user_by_name(basic_username) - if user and config.config_login_type == constants.LOGIN_LDAP and services.ldap: - if services.ldap.bind_user(str(user.password), basic_password): - return user - if user and check_password_hash(str(user.password), basic_password): - return user - return - - -def login_required_if_no_ano(func): - @wraps(func) - def decorated_view(*args, **kwargs): - if config.config_anonbrowse == 1: - return func(*args, **kwargs) - return login_required(func)(*args, **kwargs) - - return decorated_view - - -def remote_login_required(f): - @wraps(f) - def inner(*args, **kwargs): - if config.config_remote_login: - return f(*args, **kwargs) - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - data = {'status': 'error', 'message': 'Forbidden'} - response = make_response(json.dumps(data, ensure_ascii=False)) - response.headers["Content-Type"] = "application/json; charset=utf-8" - return response, 403 - abort(403) - - return inner - - -def admin_required(f): - """ - Checks if current_user.role == 1 - """ - - @wraps(f) - def inner(*args, **kwargs): - if current_user.role_admin(): - return f(*args, **kwargs) - abort(403) - - return inner - - -def unconfigured(f): - """ - Checks if calibre-web instance is not configured - """ - @wraps(f) - def inner(*args, **kwargs): - if not config.db_configured: - return f(*args, **kwargs) - abort(403) - - return inner def download_required(f): @@ -266,154 +117,6 @@ def viewer_required(f): return inner - -def upload_required(f): - @wraps(f) - def inner(*args, **kwargs): - if current_user.role_upload() or current_user.role_admin(): - return f(*args, **kwargs) - abort(403) - - return inner - - -def edit_required(f): - @wraps(f) - def inner(*args, **kwargs): - if current_user.role_edit() or current_user.role_admin(): - return f(*args, **kwargs) - abort(403) - - return inner - - -# ################################### Helper functions ################################################################ - - -@web.before_app_request -def before_request(): - if current_user.is_authenticated: - confirm_login() - g.constants = constants - g.user = current_user - g.allow_registration = config.config_public_reg - g.allow_anonymous = config.config_anonbrowse - g.allow_upload = config.config_uploading - g.current_theme = config.config_theme - g.config_authors_max = config.config_authors_max - g.shelves_access = ub.session.query(ub.Shelf).filter( - or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all() - if not config.db_configured and request.endpoint not in ( - 'admin.basic_configuration', 'login', "admin.config_pathchooser") and '/static/' not in request.path: - return redirect(url_for('admin.basic_configuration')) - - -@app.route('/import_ldap_users') -@login_required -@admin_required -def import_ldap_users(): - showtext = {} - try: - new_users = services.ldap.get_group_members(config.config_ldap_group_name) - except (services.ldap.LDAPException, TypeError, AttributeError, KeyError) as e: - log.debug_or_exception(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) - - imported = 0 - for username in new_users: - user = username.decode('utf-8') - if '=' in user: - # if member object field is empty take user object as filter - if config.config_ldap_member_user_object: - query_filter = config.config_ldap_member_user_object - else: - query_filter = config.config_ldap_user_object - try: - user_identifier = extract_user_identifier(user, query_filter) - except Exception as e: - log.warning(e) - continue - else: - user_identifier = user - query_filter = None - try: - user_data = services.ldap.get_object_details(user=user_identifier, query_filter=query_filter) - except AttributeError as e: - log.debug_or_exception(e) - continue - if user_data: - user_login_field = extract_dynamic_field_from_filter(user, config.config_ldap_user_object) - - username = user_data[user_login_field][0].decode('utf-8') - # check for duplicate username - if ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == username.lower()).first(): - log.warning("LDAP User %s Already in Database", user_data) - continue - - kindlemail = '' - if 'mail' in user_data: - useremail = user_data['mail'][0].decode('utf-8') - if (len(user_data['mail']) > 1): - kindlemail = user_data['mail'][1].decode('utf-8') - - else: - log.debug('No Mail Field Found in LDAP Response') - useremail = username + '@email.com' - # check for duplicate email - if ub.session.query(ub.User).filter(func.lower(ub.User.email) == useremail.lower()).first(): - log.warning("LDAP Email %s Already in Database", user_data) - continue - content = ub.User() - content.nickname = username - content.password = '' # dummy password which will be replaced by ldap one - content.email = useremail - content.kindle_mail = kindlemail - 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() - imported +=1 - 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'.format(imported)) - return json.dumps(showtext) - - -def extract_user_data_from_field(user, field): - match = re.search(field + "=([\d\s\w-]+)", user, re.IGNORECASE | re.UNICODE) - if match: - return match.group(1) - else: - raise Exception("Could Not Parse LDAP User: {}".format(user)) - -def extract_dynamic_field_from_filter(user, filter): - match = re.search("([a-zA-Z0-9-]+)=%s", filter, re.IGNORECASE | re.UNICODE) - if match: - return match.group(1) - else: - raise Exception("Could Not Parse LDAP Userfield: {}", user) - -def extract_user_identifier(user, filter): - dynamic_field = extract_dynamic_field_from_filter(user, filter) - return extract_user_data_from_field(user, dynamic_field) - - # ################################### data provider functions ######################################################### @@ -650,14 +353,6 @@ def get_matching_tags(): return json_dumps -# Returns the template for rendering and includes the instance name -def render_title_template(*args, **kwargs): - sidebar = ub.get_sidebar_config(kwargs) - return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, - accept=constants.EXTENSIONS_UPLOAD, - *args, **kwargs) - - def render_books_list(data, sort, book_id, page): order = [db.Books.timestamp.desc()] if sort == 'stored': @@ -735,6 +430,8 @@ def render_books_list(data, sort, book_id, page): term = json.loads(flask_session['query']) offset = int(int(config.config_books_per_page) * (page - 1)) return render_adv_search_results(term, offset, order, config.config_books_per_page) + elif data == "shelf": + return shelf.show_shelf(1, book_id) else: website = data or "newest" entries, random, pagination = calibre_db.fill_indexpage(page, 0, db.Books, True, order) @@ -1691,101 +1388,7 @@ def logout(): return redirect(url_for('web.login')) -@web.route('/remote/login') -@remote_login_required -def remote_login(): - auth_token = ub.RemoteAuthToken() - ub.session.add(auth_token) - try: - ub.session.commit() - except OperationalError: - ub.session.rollback() - verify_url = url_for('web.verify_token', token=auth_token.auth_token, _external=true) - log.debug(u"Remot Login request with token: %s", auth_token.auth_token) - return render_title_template('remote_login.html', title=_(u"login"), token=auth_token.auth_token, - verify_url=verify_url, page="remotelogin") - - -@web.route('/verify/') -@remote_login_required -@login_required -def verify_token(token): - auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first() - - # Token not found - if auth_token is None: - flash(_(u"Token not found"), category="error") - log.error(u"Remote Login token not found") - return redirect(url_for('web.index')) - - # Token expired - if datetime.now() > auth_token.expiration: - ub.session.delete(auth_token) - ub.session.commit() - - flash(_(u"Token has expired"), category="error") - log.error(u"Remote Login token expired") - return redirect(url_for('web.index')) - - # Update token with user information - auth_token.user_id = current_user.id - auth_token.verified = True - try: - ub.session.commit() - except OperationalError: - ub.session.rollback() - - flash(_(u"Success! Please return to your device"), category="success") - log.debug(u"Remote Login token for userid %s verified", auth_token.user_id) - return redirect(url_for('web.index')) - - -@web.route('/ajax/verify_token', methods=['POST']) -@remote_login_required -def token_verified(): - token = request.form['token'] - auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first() - - data = {} - - # Token not found - if auth_token is None: - data['status'] = 'error' - data['message'] = _(u"Token not found") - - # Token expired - elif datetime.now() > auth_token.expiration: - ub.session.delete(auth_token) - try: - ub.session.commit() - except OperationalError: - ub.session.rollback() - - data['status'] = 'error' - data['message'] = _(u"Token has expired") - - elif not auth_token.verified: - data['status'] = 'not_verified' - - else: - user = ub.session.query(ub.User).filter(ub.User.id == auth_token.user_id).first() - login_user(user) - - ub.session.delete(auth_token) - try: - ub.session.commit() - except OperationalError: - ub.session.rollback() - - data['status'] = 'success' - log.debug(u"Remote Login for userid %s succeded", user.id) - flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") - - response = make_response(json.dumps(data, ensure_ascii=False)) - response.headers["Content-Type"] = "application/json; charset=utf-8" - - return response # ################################### Users own configuration ######################################################### @@ -1926,14 +1529,6 @@ def read_book(book_id, book_format): log.debug(u"Start comic reader for %d", book_id) return render_title_template('readcbr.html', comicfile=all_name, title=_(u"Read a Book"), extension=fileExt) - # if feature_support['rar']: - # extensionList = ["cbr","cbt","cbz"] - # else: - # extensionList = ["cbt","cbz"] - # for fileext in extensionList: - # if book_format.lower() == fileext: - # return render_title_template('readcbr.html', comicfile=book_id, - # extension=fileext, title=_(u"Read a Book"), book=book) log.debug(u"Error opening eBook. File does not exist or file is not accessible") flash(_(u"Error opening eBook. File does not exist or file is not accessible"), category="error") return redirect(url_for("web.index"))