1
0
mirror of https://github.com/janeczku/calibre-web synced 2024-12-20 23:20:32 +00:00

Attention flask-httpAuth is additionally needed to run calibre-web

Updated pdf.js viewer
Refactored login routine (#2623)
This commit is contained in:
Ozzie Isaacs 2024-07-18 20:42:25 +02:00
commit e3be7595e2
43 changed files with 2748 additions and 1016 deletions

View File

@ -19,12 +19,12 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import ast
import hashlib
from .cw_login import LoginManager
from flask import session
from flask_login import LoginManager, confirm_login
from flask import session, current_app
from flask_login.utils import decode_cookie
from flask_login.signals import user_loaded_from_cookie
class MyLoginManager(LoginManager):
@ -36,18 +36,5 @@ class MyLoginManager(LoginManager):
return super(). _session_protection_failed()
return False
def _load_user_from_remember_cookie(self, cookie):
user_id = decode_cookie(cookie)
if user_id is not None:
session["_user_id"] = user_id
session["_fresh"] = False
user = None
if self._user_callback:
user = self._user_callback(user_id)
if user is not None:
app = current_app._get_current_object()
user_loaded_from_cookie.send(app, user=user)
# if session was restored from remember me cookie make login valid
confirm_login()
return user
return None

View File

@ -83,8 +83,8 @@ log = logger.create()
app = Flask(__name__)
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest
SESSION_COOKIE_SAMESITE='Strict',
REMEMBER_COOKIE_SAMESITE='Strict', # will be available in flask-login 0.5.1 earliest
WTF_CSRF_SSL_STRICT=False
)

View File

@ -26,12 +26,12 @@ import sqlite3
from collections import OrderedDict
import flask
import flask_login
import jinja2
from flask_babel import gettext as _
from . import db, calibre_db, converter, uploader, constants, dep_check
from .render_template import render_title_template
from .usermanagement import user_login_required
about = flask.Blueprint('about', __name__)
@ -74,7 +74,7 @@ def collect_stats():
@about.route("/stats")
@flask_login.login_required
@user_login_required
def stats():
counter = calibre_db.session.query(db.Books).count()
authors = calibre_db.session.query(db.Authors).count()

View File

@ -34,10 +34,9 @@ from urllib.parse import urlparse
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
from markupsafe import Markup
from flask_login import login_required, current_user, logout_user
from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
from flask import session as flask_session
from sqlalchemy import and_
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
@ -52,6 +51,7 @@ from .embed_helper import get_calibre_binarypath
from .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config
from .services.worker import WorkerThread
from .usermanagement import user_login_required
from .babel import get_available_translations, get_available_locale, get_user_locale_language
from . import debug_info
@ -103,13 +103,13 @@ def admin_required(f):
@admi.before_app_request
def before_request():
try:
if not ub.check_user_session(current_user.id,
flask_session.get('_id')) and 'opds' not in request.path \
and config.config_session == 1:
logout_user()
except AttributeError:
pass # ? fails on requesting /ajax/emailstat during restart ?
#try:
#if not ub.check_user_session(current_user.id,
# flask_session.get('_id')) and 'opds' not in request.path \
# and config.config_session == 1:
# logout_user()
#except AttributeError:
# pass # ? fails on requesting /ajax/emailstat during restart ?
g.constants = constants
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
g.allow_registration = config.config_public_reg
@ -129,14 +129,14 @@ def before_request():
return redirect(url_for('admin.db_configuration'))
@admi.route("/admin")
@login_required
def admin_forbidden():
abort(403)
#@admi.route("/admin")
#@user_login_required
#def admin_forbidden():
# abort(403)
@admi.route("/shutdown", methods=["POST"])
@login_required
@user_login_required
@admin_required
def shutdown():
task = request.get_json().get('parameter', -1)
@ -165,7 +165,7 @@ def shutdown():
@admi.route("/metadata_backup", methods=["POST"])
@login_required
@user_login_required
@admin_required
def queue_metadata_backup():
show_text = {}
@ -189,7 +189,7 @@ def reconnect():
@admi.route("/ajax/updateThumbnails", methods=['POST'])
@admin_required
@login_required
@user_login_required
def update_thumbnails():
content = config.get_scheduled_task_settings()
if content['schedule_generate_book_covers']:
@ -199,7 +199,7 @@ def update_thumbnails():
@admi.route("/admin/view")
@login_required
@user_login_required
@admin_required
def admin():
version = updater_thread.get_current_version_info()
@ -233,7 +233,7 @@ def admin():
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
@login_required
@user_login_required
@admin_required
def db_configuration():
if request.method == "POST":
@ -242,7 +242,7 @@ def db_configuration():
@admi.route("/admin/config", methods=["GET"])
@login_required
@user_login_required
@admin_required
def configuration():
return render_title_template("config_edit.html",
@ -253,28 +253,28 @@ def configuration():
@admi.route("/admin/ajaxconfig", methods=["POST"])
@login_required
@user_login_required
@admin_required
def ajax_config():
return _configuration_update_helper()
@admi.route("/admin/ajaxdbconfig", methods=["POST"])
@login_required
@user_login_required
@admin_required
def ajax_db_config():
return _db_configuration_update_helper()
@admi.route("/admin/alive", methods=["GET"])
@login_required
@user_login_required
@admin_required
def calibreweb_alive():
return "", 200
@admi.route("/admin/viewconfig")
@login_required
@user_login_required
@admin_required
def view_configuration():
read_column = calibre_db.session.query(db.CustomColumns) \
@ -291,7 +291,7 @@ def view_configuration():
@admi.route("/admin/usertable")
@login_required
@user_login_required
@admin_required
def edit_user_table():
visibility = current_user.view_settings.get('useredit', {})
@ -326,7 +326,7 @@ def edit_user_table():
@admi.route("/ajax/listusers")
@login_required
@user_login_required
@admin_required
def list_users():
off = int(request.args.get("offset") or 0)
@ -377,7 +377,7 @@ def list_users():
@admi.route("/ajax/deleteuser", methods=['POST'])
@login_required
@user_login_required
@admin_required
def delete_user():
user_ids = request.form.to_dict(flat=False)
@ -412,7 +412,7 @@ def delete_user():
@admi.route("/ajax/getlocale")
@login_required
@user_login_required
@admin_required
def table_get_locale():
locale = get_available_locale()
@ -424,7 +424,7 @@ def table_get_locale():
@admi.route("/ajax/getdefaultlanguage")
@login_required
@user_login_required
@admin_required
def table_get_default_lang():
languages = calibre_db.speaking_language()
@ -436,7 +436,7 @@ def table_get_default_lang():
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def edit_list_user(param):
vals = request.form.to_dict(flat=False)
@ -541,7 +541,7 @@ def edit_list_user(param):
@admi.route("/ajax/user_table_settings", methods=['POST'])
@login_required
@user_login_required
@admin_required
def update_table_settings():
current_user.view_settings['useredit'] = json.loads(request.data)
@ -558,7 +558,7 @@ def update_table_settings():
@admi.route("/admin/viewconfig", methods=["POST"])
@login_required
@user_login_required
@admin_required
def update_view_configuration():
to_save = request.form.to_dict()
@ -603,7 +603,7 @@ def update_view_configuration():
@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
@login_required
@user_login_required
def load_dialogtexts(element_id):
texts = {"header": "", "main": "", "valid": 1}
if element_id == "config_delete_kobo_token":
@ -639,7 +639,7 @@ def load_dialogtexts(element_id):
@admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def edit_domain(allow):
# POST /post
@ -653,7 +653,7 @@ def edit_domain(allow):
@admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def add_domain(allow):
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
@ -667,7 +667,7 @@ def add_domain(allow):
@admi.route("/ajax/deletedomain", methods=['POST'])
@login_required
@user_login_required
@admin_required
def delete_domain():
try:
@ -685,7 +685,7 @@ def delete_domain():
@admi.route("/ajax/domainlist/<int:allow>")
@login_required
@user_login_required
@admin_required
def list_domain(allow):
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
@ -698,7 +698,7 @@ def list_domain(allow):
@admi.route("/ajax/editrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
@admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def edit_restriction(res_type, user_id):
element = request.form.to_dict()
@ -764,14 +764,14 @@ def edit_restriction(res_type, user_id):
@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def add_user_0_restriction(res_type):
return add_restriction(res_type, 0)
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def add_restriction(res_type, user_id):
element = request.form.to_dict()
@ -817,14 +817,14 @@ def add_restriction(res_type, user_id):
@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def delete_user_0_restriction(res_type):
return delete_restriction(res_type, 0)
@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@login_required
@user_login_required
@admin_required
def delete_restriction(res_type, user_id):
element = request.form.to_dict()
@ -872,7 +872,7 @@ def delete_restriction(res_type, user_id):
@admi.route("/ajax/listrestriction/<int:res_type>", defaults={"user_id": 0})
@admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>")
@login_required
@user_login_required
@admin_required
def list_restriction(res_type, user_id):
if res_type == 0: # Tags as template
@ -916,20 +916,20 @@ def list_restriction(res_type, user_id):
@admi.route("/ajax/fullsync", methods=["POST"])
@login_required
@user_login_required
def ajax_self_fullsync():
return do_full_kobo_sync(current_user.id)
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
@login_required
@user_login_required
@admin_required
def ajax_fullsync(userid):
return do_full_kobo_sync(userid)
@admi.route("/ajax/pathchooser/")
@login_required
@user_login_required
@admin_required
def ajax_pathchooser():
return pathchooser()
@ -1246,7 +1246,7 @@ def _configuration_ldap_helper(to_save):
@admi.route("/ajax/simulatedbchange", methods=['POST'])
@login_required
@user_login_required
@admin_required
def simulatedbchange():
db_change, db_valid = _db_simulate_change()
@ -1254,7 +1254,7 @@ def simulatedbchange():
@admi.route("/admin/user/new", methods=["GET", "POST"])
@login_required
@user_login_required
@admin_required
def new_user():
content = ub.User()
@ -1276,7 +1276,7 @@ def new_user():
@admi.route("/admin/mailsettings", methods=["GET"])
@login_required
@user_login_required
@admin_required
def edit_mailsettings():
content = config.get_mail_settings()
@ -1285,7 +1285,7 @@ def edit_mailsettings():
@admi.route("/admin/mailsettings", methods=["POST"])
@login_required
@user_login_required
@admin_required
def update_mailsettings():
to_save = request.form.to_dict()
@ -1342,7 +1342,7 @@ def update_mailsettings():
@admi.route("/admin/scheduledtasks")
@login_required
@user_login_required
@admin_required
def edit_scheduledtasks():
content = config.get_scheduled_task_settings()
@ -1363,7 +1363,7 @@ def edit_scheduledtasks():
@admi.route("/admin/scheduledtasks", methods=["POST"])
@login_required
@user_login_required
@admin_required
def update_scheduledtasks():
error = False
@ -1406,7 +1406,7 @@ def update_scheduledtasks():
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
@login_required
@user_login_required
@admin_required
def edit_user(user_id):
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
@ -1435,7 +1435,7 @@ def edit_user(user_id):
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
@login_required
@user_login_required
@admin_required
def reset_user_password(user_id):
if current_user is not None and current_user.is_authenticated:
@ -1453,7 +1453,7 @@ def reset_user_password(user_id):
@admi.route("/admin/logfile")
@login_required
@user_login_required
@admin_required
def view_logfile():
logfiles = {0: logger.get_logfile(config.config_logfile),
@ -1467,7 +1467,7 @@ def view_logfile():
@admi.route("/ajax/log/<int:logtype>")
@login_required
@user_login_required
@admin_required
def send_logfile(logtype):
if logtype == 1:
@ -1483,7 +1483,7 @@ def send_logfile(logtype):
@admi.route("/admin/logdownload/<int:logtype>")
@login_required
@user_login_required
@admin_required
def download_log(logtype):
if logtype == 0:
@ -1498,14 +1498,14 @@ def download_log(logtype):
@admi.route("/admin/debug")
@login_required
@user_login_required
@admin_required
def download_debug():
return debug_info.send_debug()
@admi.route("/get_update_status", methods=['GET'])
@login_required
@user_login_required
@admin_required
def get_update_status():
if feature_support['updater']:
@ -1516,7 +1516,7 @@ def get_update_status():
@admi.route("/get_updater_status", methods=['GET', 'POST'])
@login_required
@user_login_required
@admin_required
def get_updater_status():
status = {}
@ -1611,7 +1611,7 @@ def ldap_import_create_user(user, user_data):
@admi.route('/import_ldap_users', methods=["POST"])
@login_required
@user_login_required
@admin_required
def import_ldap_users():
showtext = {}
@ -1666,7 +1666,7 @@ def import_ldap_users():
@admi.route("/ajax/canceltask", methods=['POST'])
@login_required
@user_login_required
@admin_required
def cancel_task():
task_id = request.get_json().get('task_id', None)

View File

@ -2,7 +2,7 @@ from babel import negotiate_locale
from flask_babel import Babel, Locale
from babel.core import UnknownLocaleError
from flask import request
from flask_login import current_user
from .cw_login import current_user
from . import logger

98
cps/cw_login/__init__.py Normal file
View File

@ -0,0 +1,98 @@
# from .__about__ import __version__
from .config import AUTH_HEADER_NAME
from .config import COOKIE_DURATION
from .config import COOKIE_HTTPONLY
from .config import COOKIE_NAME
from .config import COOKIE_SECURE
from .config import ID_ATTRIBUTE
from .config import LOGIN_MESSAGE
from .config import LOGIN_MESSAGE_CATEGORY
from .config import REFRESH_MESSAGE
from .config import REFRESH_MESSAGE_CATEGORY
from .login_manager import LoginManager
from .mixins import AnonymousUserMixin
from .mixins import UserMixin
from .signals import session_protected
from .signals import user_accessed
from .signals import user_loaded_from_cookie
from .signals import user_loaded_from_request
from .signals import user_logged_in
from .signals import user_logged_out
from .signals import user_login_confirmed
from .signals import user_needs_refresh
from .signals import user_unauthorized
# from .test_client import FlaskLoginClient
from .utils import confirm_login
from .utils import current_user
from .utils import decode_cookie
from .utils import encode_cookie
from .utils import fresh_login_required
from .utils import login_fresh
from .utils import login_remembered
from .utils import login_required
from .utils import login_url
from .utils import login_user
from .utils import logout_user
from .utils import make_next_param
from .utils import set_login_view
__version_info__ = ("0", "6", "3")
__version__ = ".".join(__version_info__)
__all__ = [
"__version__",
"AUTH_HEADER_NAME",
"COOKIE_DURATION",
"COOKIE_HTTPONLY",
"COOKIE_NAME",
"COOKIE_SECURE",
"ID_ATTRIBUTE",
"LOGIN_MESSAGE",
"LOGIN_MESSAGE_CATEGORY",
"REFRESH_MESSAGE",
"REFRESH_MESSAGE_CATEGORY",
"LoginManager",
"AnonymousUserMixin",
"UserMixin",
"session_protected",
"user_accessed",
"user_loaded_from_cookie",
"user_loaded_from_request",
"user_logged_in",
"user_logged_out",
"user_login_confirmed",
"user_needs_refresh",
"user_unauthorized",
# "FlaskLoginClient",
"confirm_login",
"current_user",
"decode_cookie",
"encode_cookie",
"fresh_login_required",
"login_fresh",
"login_remembered",
"login_required",
"login_url",
"login_user",
"logout_user",
"make_next_param",
"set_login_view",
]
def __getattr__(name):
if name == "user_loaded_from_header":
import warnings
from .signals import _user_loaded_from_header
warnings.warn(
"'user_loaded_from_header' is deprecated and will be"
" removed in Flask-Login 0.7. Use"
" 'user_loaded_from_request' instead.",
DeprecationWarning,
stacklevel=2,
)
return _user_loaded_from_header
raise AttributeError(name)

55
cps/cw_login/config.py Normal file
View File

@ -0,0 +1,55 @@
from datetime import timedelta
#: The default name of the "remember me" cookie (``remember_token``)
COOKIE_NAME = "remember_token"
#: The default time before the "remember me" cookie expires (365 days).
COOKIE_DURATION = timedelta(days=365)
#: Whether the "remember me" cookie requires Secure; defaults to ``False``
COOKIE_SECURE = False
#: Whether the "remember me" cookie uses HttpOnly or not; defaults to ``True``
COOKIE_HTTPONLY = True
#: Whether the "remember me" cookie requires same origin; defaults to ``None``
COOKIE_SAMESITE = None
#: The default flash message to display when users need to log in.
LOGIN_MESSAGE = "Please log in to access this page."
#: The default flash message category to display when users need to log in.
LOGIN_MESSAGE_CATEGORY = "message"
#: The default flash message to display when users need to reauthenticate.
REFRESH_MESSAGE = "Please reauthenticate to access this page."
#: The default flash message category to display when users need to
#: reauthenticate.
REFRESH_MESSAGE_CATEGORY = "message"
#: The default attribute to retreive the str id of the user
ID_ATTRIBUTE = "get_id"
#: Default name of the auth header (``Authorization``)
AUTH_HEADER_NAME = "Authorization"
#: A set of session keys that are populated by Flask-Login. Use this set to
#: purge keys safely and accurately.
SESSION_KEYS = {
"_user_id",
"_remember",
"_remember_seconds",
"_id",
"_fresh",
"next",
}
#: A set of HTTP methods which are exempt from `login_required` and
#: `fresh_login_required`. By default, this is just ``OPTIONS``.
EXEMPT_METHODS = {"OPTIONS"}
#: If true, the page the user is attempting to access is stored in the session
#: rather than a url parameter when redirecting to the login view; defaults to
#: ``False``.
USE_SESSION_FOR_NEXT = False

View File

@ -0,0 +1,554 @@
from datetime import datetime
from datetime import timedelta
import hashlib
from flask import abort
from flask import current_app
from flask import flash
from flask import g
from flask import has_app_context
from flask import redirect
from flask import request
from flask import session
from itsdangerous import URLSafeSerializer
from flask.json.tag import TaggedJSONSerializer
from .config import AUTH_HEADER_NAME
from .config import COOKIE_DURATION
from .config import COOKIE_HTTPONLY
from .config import COOKIE_NAME
from .config import COOKIE_SAMESITE
from .config import COOKIE_SECURE
from .config import ID_ATTRIBUTE
from .config import LOGIN_MESSAGE
from .config import LOGIN_MESSAGE_CATEGORY
from .config import REFRESH_MESSAGE
from .config import REFRESH_MESSAGE_CATEGORY
from .config import SESSION_KEYS
from .config import USE_SESSION_FOR_NEXT
from .mixins import AnonymousUserMixin
from .signals import session_protected
from .signals import user_accessed
from .signals import user_loaded_from_cookie
from .signals import user_loaded_from_request
from .signals import user_needs_refresh
from .signals import user_unauthorized
from .utils import _create_identifier
from .utils import _user_context_processor
from .utils import confirm_login
from .utils import expand_login_view
from .utils import login_url as make_login_url
from .utils import make_next_param
class LoginManager:
"""This object is used to hold the settings used for logging in. Instances
of :class:`LoginManager` are *not* bound to specific apps, so you can
create one in the main body of your code and then bind it to your
app in a factory function.
"""
def __init__(self, app=None, add_context_processor=True):
#: A class or factory function that produces an anonymous user, which
#: is used when no one is logged in.
self.anonymous_user = AnonymousUserMixin
#: The name of the view to redirect to when the user needs to log in.
#: (This can be an absolute URL as well, if your authentication
#: machinery is external to your application.)
self.login_view = None
#: Names of views to redirect to when the user needs to log in,
#: per blueprint. If the key value is set to None the value of
#: :attr:`login_view` will be used instead.
self.blueprint_login_views = {}
#: The message to flash when a user is redirected to the login page.
self.login_message = LOGIN_MESSAGE
#: The message category to flash when a user is redirected to the login
#: page.
self.login_message_category = LOGIN_MESSAGE_CATEGORY
#: The name of the view to redirect to when the user needs to
#: reauthenticate.
self.refresh_view = None
#: The message to flash when a user is redirected to the 'needs
#: refresh' page.
self.needs_refresh_message = REFRESH_MESSAGE
#: The message category to flash when a user is redirected to the
#: 'needs refresh' page.
self.needs_refresh_message_category = REFRESH_MESSAGE_CATEGORY
#: The mode to use session protection in. This can be either
#: ``'basic'`` (the default) or ``'strong'``, or ``None`` to disable
#: it.
self.session_protection = "basic"
#: If present, used to translate flash messages ``self.login_message``
#: and ``self.needs_refresh_message``
self.localize_callback = None
self.unauthorized_callback = None
self.needs_refresh_callback = None
self.id_attribute = ID_ATTRIBUTE
self._user_callback = None
self._header_callback = None
self._request_callback = None
self._session_identifier_generator = _create_identifier
if app is not None:
self.init_app(app, add_context_processor)
def setup_app(self, app, add_context_processor=True): # pragma: no cover
"""
This method has been deprecated. Please use
:meth:`LoginManager.init_app` instead.
"""
import warnings
warnings.warn(
"'setup_app' is deprecated and will be removed in"
" Flask-Login 0.7. Use 'init_app' instead.",
DeprecationWarning,
stacklevel=2,
)
self.init_app(app, add_context_processor)
def init_app(self, app, add_context_processor=True):
"""
Configures an application. This registers an `after_request` call, and
attaches this `LoginManager` to it as `app.login_manager`.
:param app: The :class:`flask.Flask` object to configure.
:type app: :class:`flask.Flask`
:param add_context_processor: Whether to add a context processor to
the app that adds a `current_user` variable to the template.
Defaults to ``True``.
:type add_context_processor: bool
"""
app.login_manager = self
app.after_request(self._update_remember_cookie)
if add_context_processor:
app.context_processor(_user_context_processor)
def unauthorized(self):
"""
This is called when the user is required to log in. If you register a
callback with :meth:`LoginManager.unauthorized_handler`, then it will
be called. Otherwise, it will take the following actions:
- Flash :attr:`LoginManager.login_message` to the user.
- If the app is using blueprints find the login view for
the current blueprint using `blueprint_login_views`. If the app
is not using blueprints or the login view for the current
blueprint is not specified use the value of `login_view`.
- Redirect the user to the login view. (The page they were
attempting to access will be passed in the ``next`` query
string variable, so you can redirect there if present instead
of the homepage. Alternatively, it will be added to the session
as ``next`` if USE_SESSION_FOR_NEXT is set.)
If :attr:`LoginManager.login_view` is not defined, then it will simply
raise a HTTP 401 (Unauthorized) error instead.
This should be returned from a view or before/after_request function,
otherwise the redirect will have no effect.
"""
user_unauthorized.send(current_app._get_current_object())
if self.unauthorized_callback:
return self.unauthorized_callback()
if request.blueprint in self.blueprint_login_views:
login_view = self.blueprint_login_views[request.blueprint]
else:
login_view = self.login_view
if not login_view:
abort(401)
if self.login_message:
if self.localize_callback is not None:
flash(
self.localize_callback(self.login_message),
category=self.login_message_category,
)
else:
flash(self.login_message, category=self.login_message_category)
config = current_app.config
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
login_url = expand_login_view(login_view)
session["_id"] = self._session_identifier_generator()
session["next"] = make_next_param(login_url, request.url)
redirect_url = make_login_url(login_view)
else:
redirect_url = make_login_url(login_view, next_url=request.url)
return redirect(redirect_url)
def user_loader(self, callback):
"""
This sets the callback for reloading a user from the session. The
function you set should take a user ID (a ``str``) and return a
user object, or ``None`` if the user does not exist.
:param callback: The callback for retrieving a user object.
:type callback: callable
"""
self._user_callback = callback
return self.user_callback
@property
def user_callback(self):
"""Gets the user_loader callback set by user_loader decorator."""
return self._user_callback
def request_loader(self, callback):
"""
This sets the callback for loading a user from a Flask request.
The function you set should take Flask request object and
return a user object, or `None` if the user does not exist.
:param callback: The callback for retrieving a user object.
:type callback: callable
"""
self._request_callback = callback
return self.request_callback
@property
def request_callback(self):
"""Gets the request_loader callback set by request_loader decorator."""
return self._request_callback
def unauthorized_handler(self, callback):
"""
This will set the callback for the `unauthorized` method, which among
other things is used by `login_required`. It takes no arguments, and
should return a response to be sent to the user instead of their
normal view.
:param callback: The callback for unauthorized users.
:type callback: callable
"""
self.unauthorized_callback = callback
return callback
def needs_refresh_handler(self, callback):
"""
This will set the callback for the `needs_refresh` method, which among
other things is used by `fresh_login_required`. It takes no arguments,
and should return a response to be sent to the user instead of their
normal view.
:param callback: The callback for unauthorized users.
:type callback: callable
"""
self.needs_refresh_callback = callback
return callback
def needs_refresh(self):
"""
This is called when the user is logged in, but they need to be
reauthenticated because their session is stale. If you register a
callback with `needs_refresh_handler`, then it will be called.
Otherwise, it will take the following actions:
- Flash :attr:`LoginManager.needs_refresh_message` to the user.
- Redirect the user to :attr:`LoginManager.refresh_view`. (The page
they were attempting to access will be passed in the ``next``
query string variable, so you can redirect there if present
instead of the homepage.)
If :attr:`LoginManager.refresh_view` is not defined, then it will
simply raise a HTTP 401 (Unauthorized) error instead.
This should be returned from a view or before/after_request function,
otherwise the redirect will have no effect.
"""
user_needs_refresh.send(current_app._get_current_object())
if self.needs_refresh_callback:
return self.needs_refresh_callback()
if not self.refresh_view:
abort(401)
if self.needs_refresh_message:
if self.localize_callback is not None:
flash(
self.localize_callback(self.needs_refresh_message),
category=self.needs_refresh_message_category,
)
else:
flash(
self.needs_refresh_message,
category=self.needs_refresh_message_category,
)
config = current_app.config
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
login_url = expand_login_view(self.refresh_view)
session["_id"] = self._session_identifier_generator()
session["next"] = make_next_param(login_url, request.url)
redirect_url = make_login_url(self.refresh_view)
else:
login_url = self.refresh_view
redirect_url = make_login_url(login_url, next_url=request.url)
return redirect(redirect_url)
def _update_request_context_with_user(self, user=None):
"""Store the given user as ctx.user."""
if user is None:
user = self.anonymous_user()
g._login_user = user
def _load_user(self):
"""Loads user from session or remember_me cookie as applicable"""
if self._user_callback is None and self._request_callback is None:
raise Exception(
"Missing user_loader or request_loader. Refer to "
"https://flask-login.readthedocs.io/#how-it-works "
"for more info."
)
user_accessed.send(current_app._get_current_object())
# Check SESSION_PROTECTION
if self._session_protection_failed():
return self._update_request_context_with_user()
user = None
# Load user from Flask Session
user_id = session.get("_user_id")
user_random = session.get("_random")
user_session_key = session.get("_id")
if (user_id is not None
and user_random is not None
and user_session_key is not None
and self._user_callback is not None):
user = self._user_callback(user_id, user_random, user_session_key)
# Load user from Remember Me Cookie or Request Loader
if user is None:
config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
header_name = config.get("AUTH_HEADER_NAME", AUTH_HEADER_NAME)
has_cookie = (
cookie_name in request.cookies and session.get("_remember") != "clear"
)
if has_cookie:
cookie = request.cookies[cookie_name]
user = self._load_user_from_remember_cookie(cookie)
elif self._request_callback:
user = self._load_user_from_request(request)
elif header_name in request.headers:
header = request.headers[header_name]
user = self._load_user_from_header(header)
if not user:
self._update_request_context_with_user()
return self._update_request_context_with_user(user)
def _session_protection_failed(self):
sess = session._get_current_object()
ident = self._session_identifier_generator()
app = current_app._get_current_object()
mode = app.config.get("SESSION_PROTECTION", self.session_protection)
if not mode or mode not in ["basic", "strong"]:
return False
# if the sess is empty, it's an anonymous user or just logged out
# so we can skip this
if sess and ident != sess.get("_id", None):
if mode == "basic" or sess.permanent:
if sess.get("_fresh") is not False:
sess["_fresh"] = False
session_protected.send(app)
return False
elif mode == "strong":
for k in SESSION_KEYS:
sess.pop(k, None)
sess["_remember"] = "clear"
session_protected.send(app)
return True
return False
def _load_user_from_remember_cookie(self, cookie):
signer_kwargs = dict(
key_derivation="hmac", digest_method=staticmethod(hashlib.sha1)
)
try:
remember_dict = URLSafeSerializer(
current_app.secret_key,
salt="remember",
serializer=TaggedJSONSerializer(),
signer_kwargs=signer_kwargs,
).loads(cookie)
except Exception:
return None
if remember_dict['user'] is not None:
session["_user_id"] = remember_dict['user']
if "_random" not in session:
session["_random"] = remember_dict['random']
session["_fresh"] = False
user = None
if self._user_callback:
user = self._user_callback(remember_dict['user'], session["_random"], None)
if user is not None:
app = current_app._get_current_object()
user_loaded_from_cookie.send(app, user=user)
# if session was restored from remember me cookie make login valid
confirm_login()
return user
return None
def _load_user_from_header(self, header):
if self._header_callback:
user = self._header_callback(header)
if user is not None:
app = current_app._get_current_object()
from .signals import _user_loaded_from_header
_user_loaded_from_header.send(app, user=user)
return user
return None
def _load_user_from_request(self, request):
if self._request_callback:
user = self._request_callback(request)
if user is not None:
app = current_app._get_current_object()
user_loaded_from_request.send(app, user=user)
return user
return None
def _update_remember_cookie(self, response):
# Don't modify the session unless there's something to do.
if "_remember" not in session and current_app.config.get(
"REMEMBER_COOKIE_REFRESH_EACH_REQUEST"
):
session["_remember"] = "set"
if "_remember" in session:
operation = session.pop("_remember", None)
if operation == "set" and "_user_id" in session:
self._set_cookie(response)
elif operation == "clear":
self._clear_cookie(response)
return response
def _set_cookie(self, response):
# cookie settings
config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
domain = config.get("REMEMBER_COOKIE_DOMAIN")
path = config.get("REMEMBER_COOKIE_PATH", "/")
secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
samesite = config.get("REMEMBER_COOKIE_SAMESITE", COOKIE_SAMESITE)
if "_remember_seconds" in session:
duration = timedelta(seconds=session["_remember_seconds"])
else:
duration = config.get("REMEMBER_COOKIE_DURATION", COOKIE_DURATION)
# prepare data
max_age = int(current_app.permanent_session_lifetime.total_seconds())
signer_kwargs = dict(
key_derivation="hmac", digest_method=staticmethod(hashlib.sha1)
)
# save
data = URLSafeSerializer(
current_app.secret_key,
salt="remember",
serializer=TaggedJSONSerializer(),
signer_kwargs=signer_kwargs,
).dumps({"user":session["_user_id"], "random":session["_random"]})
if isinstance(duration, int):
duration = timedelta(seconds=duration)
try:
expires = datetime.utcnow() + duration
except TypeError as e:
raise Exception(
"REMEMBER_COOKIE_DURATION must be a datetime.timedelta,"
f" instead got: {duration}"
) from e
# actually set it
response.set_cookie(
cookie_name,
value=data,
expires=expires,
domain=domain,
path=path,
secure=secure,
httponly=httponly,
samesite=samesite,
)
def _clear_cookie(self, response):
config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
domain = config.get("REMEMBER_COOKIE_DOMAIN")
path = config.get("REMEMBER_COOKIE_PATH", "/")
response.delete_cookie(cookie_name, domain=domain, path=path)
@property
def _login_disabled(self):
"""Legacy property, use app.config['LOGIN_DISABLED'] instead."""
import warnings
warnings.warn(
"'_login_disabled' is deprecated and will be removed in"
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
" instead.",
DeprecationWarning,
stacklevel=2,
)
if has_app_context():
return current_app.config.get("LOGIN_DISABLED", False)
return False
@_login_disabled.setter
def _login_disabled(self, newvalue):
"""Legacy property setter, use app.config['LOGIN_DISABLED'] instead."""
import warnings
warnings.warn(
"'_login_disabled' is deprecated and will be removed in"
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
" instead.",
DeprecationWarning,
stacklevel=2,
)
current_app.config["LOGIN_DISABLED"] = newvalue

65
cps/cw_login/mixins.py Normal file
View File

@ -0,0 +1,65 @@
class UserMixin:
"""
This provides default implementations for the methods that Flask-Login
expects user objects to have.
"""
# Python 3 implicitly set __hash__ to None if we override __eq__
# We set it back to its default implementation
__hash__ = object.__hash__
@property
def is_active(self):
return True
@property
def is_authenticated(self):
return self.is_active
@property
def is_anonymous(self):
return False
def get_id(self):
try:
return str(self.id)
except AttributeError:
raise NotImplementedError("No `id` attribute - override `get_id`") from None
def __eq__(self, other):
"""
Checks the equality of two `UserMixin` objects using `get_id`.
"""
if isinstance(other, UserMixin):
return self.get_id() == other.get_id()
return NotImplemented
def __ne__(self, other):
"""
Checks the inequality of two `UserMixin` objects using `get_id`.
"""
equal = self.__eq__(other)
if equal is NotImplemented:
return NotImplemented
return not equal
class AnonymousUserMixin:
"""
This is the default object for representing an anonymous user.
"""
@property
def is_authenticated(self):
return False
@property
def is_active(self):
return False
@property
def is_anonymous(self):
return True
def get_id(self):
return

61
cps/cw_login/signals.py Normal file
View File

@ -0,0 +1,61 @@
from flask.signals import Namespace
_signals = Namespace()
#: Sent when a user is logged in. In addition to the app (which is the
#: sender), it is passed `user`, which is the user being logged in.
user_logged_in = _signals.signal("logged-in")
#: Sent when a user is logged out. In addition to the app (which is the
#: sender), it is passed `user`, which is the user being logged out.
user_logged_out = _signals.signal("logged-out")
#: Sent when the user is loaded from the cookie. In addition to the app (which
#: is the sender), it is passed `user`, which is the user being reloaded.
user_loaded_from_cookie = _signals.signal("loaded-from-cookie")
#: Sent when the user is loaded from the header. In addition to the app (which
#: is the #: sender), it is passed `user`, which is the user being reloaded.
_user_loaded_from_header = _signals.signal("loaded-from-header")
#: Sent when the user is loaded from the request. In addition to the app (which
#: is the #: sender), it is passed `user`, which is the user being reloaded.
user_loaded_from_request = _signals.signal("loaded-from-request")
#: Sent when a user's login is confirmed, marking it as fresh. (It is not
#: called for a normal login.)
#: It receives no additional arguments besides the app.
user_login_confirmed = _signals.signal("login-confirmed")
#: Sent when the `unauthorized` method is called on a `LoginManager`. It
#: receives no additional arguments besides the app.
user_unauthorized = _signals.signal("unauthorized")
#: Sent when the `needs_refresh` method is called on a `LoginManager`. It
#: receives no additional arguments besides the app.
user_needs_refresh = _signals.signal("needs-refresh")
#: Sent whenever the user is accessed/loaded
#: receives no additional arguments besides the app.
user_accessed = _signals.signal("accessed")
#: Sent whenever session protection takes effect, and a session is either
#: marked non-fresh or deleted. It receives no additional arguments besides
#: the app.
session_protected = _signals.signal("session-protected")
def __getattr__(name):
if name == "user_loaded_from_header":
import warnings
warnings.warn(
"'user_loaded_from_header' is deprecated and will be"
" removed in Flask-Login 0.7. Use"
" 'user_loaded_from_request' instead.",
DeprecationWarning,
stacklevel=2,
)
return _user_loaded_from_header
raise AttributeError(name)

424
cps/cw_login/utils.py Normal file
View File

@ -0,0 +1,424 @@
import hmac
import os
from functools import wraps
from hashlib import sha512
from urllib.parse import parse_qs
from urllib.parse import urlencode
from urllib.parse import urlsplit
from urllib.parse import urlunsplit
from flask import current_app
from flask import g
from flask import has_request_context
from flask import request
from flask import session
from flask import url_for
from werkzeug.local import LocalProxy
from .config import COOKIE_NAME
from .config import EXEMPT_METHODS
from .signals import user_logged_in
from .signals import user_logged_out
from .signals import user_login_confirmed
#: A proxy for the current user. If no user is logged in, this will be an
#: anonymous user
current_user = LocalProxy(lambda: _get_user())
def encode_cookie(payload, key=None):
"""
This will encode a ``str`` value into a cookie, and sign that cookie
with the app's secret key.
:param payload: The value to encode, as `str`.
:type payload: str
:param key: The key to use when creating the cookie digest. If not
specified, the SECRET_KEY value from app config will be used.
:type key: str
"""
return f"{payload}|{_cookie_digest(payload, key=key)}"
def decode_cookie(cookie, key=None):
"""
This decodes a cookie given by `encode_cookie`. If verification of the
cookie fails, ``None`` will be implicitly returned.
:param cookie: An encoded cookie.
:type cookie: str
:param key: The key to use when creating the cookie digest. If not
specified, the SECRET_KEY value from app config will be used.
:type key: str
"""
try:
payload, digest = cookie.rsplit("|", 1)
if hasattr(digest, "decode"):
digest = digest.decode("ascii") # pragma: no cover
except ValueError:
return
if hmac.compare_digest(_cookie_digest(payload, key=key), digest):
return payload
def make_next_param(login_url, current_url):
"""
Reduces the scheme and host from a given URL so it can be passed to
the given `login` URL more efficiently.
:param login_url: The login URL being redirected to.
:type login_url: str
:param current_url: The URL to reduce.
:type current_url: str
"""
l_url = urlsplit(login_url)
c_url = urlsplit(current_url)
if (not l_url.scheme or l_url.scheme == c_url.scheme) and (
not l_url.netloc or l_url.netloc == c_url.netloc
):
return urlunsplit(("", "", c_url.path, c_url.query, ""))
return current_url
def expand_login_view(login_view):
"""
Returns the url for the login view, expanding the view name to a url if
needed.
:param login_view: The name of the login view or a URL for the login view.
:type login_view: str
"""
if login_view.startswith(("https://", "http://", "/")):
return login_view
return url_for(login_view)
def login_url(login_view, next_url=None, next_field="next"):
"""
Creates a URL for redirecting to a login page. If only `login_view` is
provided, this will just return the URL for it. If `next_url` is provided,
however, this will append a ``next=URL`` parameter to the query string
so that the login view can redirect back to that URL. Flask-Login's default
unauthorized handler uses this function when redirecting to your login url.
To force the host name used, set `FORCE_HOST_FOR_REDIRECTS` to a host. This
prevents from redirecting to external sites if request headers Host or
X-Forwarded-For are present.
:param login_view: The name of the login view. (Alternately, the actual
URL to the login view.)
:type login_view: str
:param next_url: The URL to give the login view for redirection.
:type next_url: str
:param next_field: What field to store the next URL in. (It defaults to
``next``.)
:type next_field: str
"""
base = expand_login_view(login_view)
if next_url is None:
return base
parsed_result = urlsplit(base)
md = parse_qs(parsed_result.query, keep_blank_values=True)
md[next_field] = make_next_param(base, next_url)
netloc = current_app.config.get("FORCE_HOST_FOR_REDIRECTS") or parsed_result.netloc
parsed_result = parsed_result._replace(
netloc=netloc, query=urlencode(md, doseq=True)
)
return urlunsplit(parsed_result)
def login_fresh():
"""
This returns ``True`` if the current login is fresh.
"""
return session.get("_fresh", False)
def login_remembered():
"""
This returns ``True`` if the current login is remembered across sessions.
"""
config = current_app.config
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
has_cookie = cookie_name in request.cookies and session.get("_remember") != "clear"
if has_cookie:
cookie = request.cookies[cookie_name]
user_id = decode_cookie(cookie)
return user_id is not None
return False
def login_user(user, remember=False, duration=None, force=False, fresh=True):
"""
Logs a user in. You should pass the actual user object to this. If the
user's `is_active` property is ``False``, they will not be logged in
unless `force` is ``True``.
This will return ``True`` if the log in attempt succeeds, and ``False`` if
it fails (i.e. because the user is inactive).
:param user: The user object to log in.
:type user: object
:param remember: Whether to remember the user after their session expires.
Defaults to ``False``.
:type remember: bool
:param duration: The amount of time before the remember cookie expires. If
``None`` the value set in the settings is used. Defaults to ``None``.
:type duration: :class:`datetime.timedelta`
:param force: If the user is inactive, setting this to ``True`` will log
them in regardless. Defaults to ``False``.
:type force: bool
:param fresh: setting this to ``False`` will log in the user with a session
marked as not "fresh". Defaults to ``True``.
:type fresh: bool
"""
if not force and not user.is_active:
return False
user_id = getattr(user, current_app.login_manager.id_attribute)()
session["_user_id"] = user_id
session["_fresh"] = fresh
session["_id"] = current_app.login_manager._session_identifier_generator()
session["_random"] = os.urandom(10).hex()
if remember:
session["_remember"] = "set"
if duration is not None:
try:
# equal to timedelta.total_seconds() but works with Python 2.6
session["_remember_seconds"] = (
duration.microseconds
+ (duration.seconds + duration.days * 24 * 3600) * 10**6
) / 10.0**6
except AttributeError as e:
raise Exception(
f"duration must be a datetime.timedelta, instead got: {duration}"
) from e
current_app.login_manager._update_request_context_with_user(user)
user_logged_in.send(current_app._get_current_object(), user=_get_user())
return True
def logout_user():
"""
Logs a user out. (You do not need to pass the actual user.) This will
also clean up the remember me cookie if it exists.
"""
user = _get_user()
if "_user_id" in session:
session.pop("_user_id")
if "_fresh" in session:
session.pop("_fresh")
if "_id" in session:
session.pop("_id")
if "_random" in session:
session.pop("_random")
cookie_name = current_app.config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
if cookie_name in request.cookies:
session["_remember"] = "clear"
if "_remember_seconds" in session:
session.pop("_remember_seconds")
user_logged_out.send(current_app._get_current_object(), user=user)
current_app.login_manager._update_request_context_with_user()
return True
def confirm_login():
"""
This sets the current session as fresh. Sessions become stale when they
are reloaded from a cookie.
"""
session["_fresh"] = True
session["_id"] = current_app.login_manager._session_identifier_generator()
user_login_confirmed.send(current_app._get_current_object())
def login_required(func):
"""
If you decorate a view with this, it will ensure that the current user is
logged in and authenticated before calling the actual view. (If they are
not, it calls the :attr:`LoginManager.unauthorized` callback.) For
example::
@app.route('/post')
@user_login_required
def post():
pass
If there are only certain times you need to require that your user is
logged in, you can do so with::
if not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
...which is essentially the code that this function adds to your views.
It can be convenient to globally turn off authentication when unit testing.
To enable this, if the application configuration variable `LOGIN_DISABLED`
is set to `True`, this decorator will be ignored.
.. Note ::
Per `W3 guidelines for CORS preflight requests
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
HTTP ``OPTIONS`` requests are exempt from login checks.
:param func: The view function to decorate.
:type func: function
"""
@wraps(func)
def decorated_view(*args, **kwargs):
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
pass
elif not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
# flask 1.x compatibility
# current_app.ensure_sync is only available in Flask >= 2.0
if callable(getattr(current_app, "ensure_sync", None)):
return current_app.ensure_sync(func)(*args, **kwargs)
return func(*args, **kwargs)
return decorated_view
def fresh_login_required(func):
"""
If you decorate a view with this, it will ensure that the current user's
login is fresh - i.e. their session was not restored from a 'remember me'
cookie. Sensitive operations, like changing a password or e-mail, should
be protected with this, to impede the efforts of cookie thieves.
If the user is not authenticated, :meth:`LoginManager.unauthorized` is
called as normal. If they are authenticated, but their session is not
fresh, it will call :meth:`LoginManager.needs_refresh` instead. (In that
case, you will need to provide a :attr:`LoginManager.refresh_view`.)
Behaves identically to the :func:`login_required` decorator with respect
to configuration variables.
.. Note ::
Per `W3 guidelines for CORS preflight requests
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
HTTP ``OPTIONS`` requests are exempt from login checks.
:param func: The view function to decorate.
:type func: function
"""
@wraps(func)
def decorated_view(*args, **kwargs):
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
pass
elif not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
elif not login_fresh():
return current_app.login_manager.needs_refresh()
try:
# current_app.ensure_sync available in Flask >= 2.0
return current_app.ensure_sync(func)(*args, **kwargs)
except AttributeError: # pragma: no cover
return func(*args, **kwargs)
return decorated_view
def set_login_view(login_view, blueprint=None):
"""
Sets the login view for the app or blueprint. If a blueprint is passed,
the login view is set for this blueprint on ``blueprint_login_views``.
:param login_view: The user object to log in.
:type login_view: str
:param blueprint: The blueprint which this login view should be set on.
Defaults to ``None``.
:type blueprint: object
"""
num_login_views = len(current_app.login_manager.blueprint_login_views)
if blueprint is not None or num_login_views != 0:
(current_app.login_manager.blueprint_login_views[blueprint.name]) = login_view
if (
current_app.login_manager.login_view is not None
and None not in current_app.login_manager.blueprint_login_views
):
(
current_app.login_manager.blueprint_login_views[None]
) = current_app.login_manager.login_view
current_app.login_manager.login_view = None
else:
current_app.login_manager.login_view = login_view
def _get_user():
if has_request_context():
if "flask_httpauth_user" in g:
if g.flask_httpauth_user is not None:
return g.flask_httpauth_user
if "_login_user" not in g:
current_app.login_manager._load_user()
return g._login_user
return None
def _cookie_digest(payload, key=None):
key = _secret_key(key)
return hmac.new(key, payload.encode("utf-8"), sha512).hexdigest()
def _get_remote_addr():
address = request.headers.get("X-Forwarded-For", request.remote_addr)
if address is not None:
# An 'X-Forwarded-For' header includes a comma separated list of the
# addresses, the first address being the actual remote address.
address = address.encode("utf-8").split(b",")[0].strip()
return address
def _create_identifier():
user_agent = request.headers.get("User-Agent")
if user_agent is not None:
user_agent = user_agent.encode("utf-8")
base = f"{_get_remote_addr()}|{user_agent}"
if str is bytes:
base = str(base, "utf-8", errors="replace") # pragma: no cover
h = sha512()
h.update(base.encode("utf8"))
return h.hexdigest()
def _user_context_processor():
return dict(current_user=_get_user())
def _secret_key(key=None):
if key is None:
key = current_app.config["SECRET_KEY"]
if isinstance(key, str): # pragma: no cover
key = key.encode("latin1") # ensure bytes
return key

View File

@ -23,6 +23,7 @@ import json
from datetime import datetime
from urllib.parse import quote
import unidecode
from weakref import WeakSet
from sqlite3 import OperationalError as sqliteOperationalError
from sqlalchemy import create_engine
@ -40,7 +41,7 @@ except ImportError:
from sqlalchemy.pool import StaticPool
from sqlalchemy.sql.expression import and_, true, false, text, func, or_
from sqlalchemy.ext.associationproxy import association_proxy
from flask_login import current_user
from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import get_locale
from flask import flash
@ -48,8 +49,6 @@ from flask import flash
from . import logger, ub, isoLanguages
from .pagination import Pagination
from weakref import WeakSet
log = logger.create()

View File

@ -32,7 +32,7 @@ from flask import Blueprint, request, flash, redirect, url_for, abort, Response
from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_
from flask_babel import get_locale
from flask_login import current_user, login_required
from .cw_login import current_user, login_required
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
from sqlalchemy.orm.exc import StaleDataError
from sqlalchemy.sql.expression import func
@ -43,10 +43,11 @@ from . import config, ub, db, calibre_db
from .services.worker import WorkerThread
from .tasks.upload import TaskUpload
from .render_template import render_title_template
from .usermanagement import login_required_if_no_ano
from .kobo_sync_status import change_archived_books
from .redirect import get_redirect_location
from .file_helper import validate_mime_type
from .usermanagement import user_login_required, login_required_if_no_ano
editbook = Blueprint('edit-book', __name__)
log = logger.create()
@ -73,14 +74,14 @@ def edit_required(f):
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
@login_required
@user_login_required
def delete_book_from_details(book_id):
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
@login_required
@user_login_required
def delete_book_ajax(book_id, book_format):
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
@ -331,7 +332,7 @@ def convert_bookformat(book_id):
@editbook.route("/ajax/getcustomenum/<int:c_id>")
@login_required
@user_login_required
def table_get_custom_enum(c_id):
ret = list()
cc = (calibre_db.session.query(db.CustomColumns)
@ -455,7 +456,7 @@ def edit_list_book(param):
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
@login_required
@user_login_required
def get_sorted_entry(field, bookid):
if field in ['title', 'authors', 'sort', 'author_sort']:
book = calibre_db.get_filtered_book(bookid)
@ -472,7 +473,7 @@ def get_sorted_entry(field, bookid):
@editbook.route("/ajax/simulatemerge", methods=['POST'])
@login_required
@user_login_required
@edit_required
def simulate_merge_list_book():
vals = request.get_json().get('Merge_books')
@ -488,7 +489,7 @@ def simulate_merge_list_book():
@editbook.route("/ajax/mergebooks", methods=['POST'])
@login_required
@user_login_required
@edit_required
def merge_list_book():
vals = request.get_json().get('Merge_books')
@ -526,7 +527,7 @@ def merge_list_book():
@editbook.route("/ajax/xchange", methods=['POST'])
@login_required
@user_login_required
@edit_required
def table_xchange_author_title():
vals = request.get_json().get('xchange')

View File

@ -29,11 +29,11 @@ from shutil import move, copyfile
from flask import Blueprint, flash, request, redirect, url_for, abort
from flask_babel import gettext as _
from flask_login import login_required
from . import logger, gdriveutils, config, ub, calibre_db, csrf
from .admin import admin_required
from .file_helper import get_temp_dir
from .usermanagement import user_login_required
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
log = logger.create()
@ -49,7 +49,7 @@ gdrive_watch_callback_token = 'target=calibreweb-watch_files' # nosec
@gdrive.route("/authenticate")
@login_required
@user_login_required
@admin_required
def authenticate_google_drive():
try:
@ -76,7 +76,7 @@ def google_drive_callback():
@gdrive.route("/watch/subscribe")
@login_required
@user_login_required
@admin_required
def watch_gdrive():
if not config.config_google_drive_watch_changes_response:
@ -102,7 +102,7 @@ def watch_gdrive():
@gdrive.route("/watch/revoke")
@login_required
@user_login_required
@admin_required
def revoke_watch_gdrive():
last_watch_response = config.config_google_drive_watch_changes_response

View File

@ -34,7 +34,7 @@ from flask import send_from_directory, make_response, abort, url_for, Response
from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_
from flask_babel import get_locale
from flask_login import current_user
from .cw_login import current_user
from sqlalchemy.sql.expression import true, false, and_, or_, text, func
from sqlalchemy.exc import InvalidRequestError, OperationalError
from werkzeug.datastructures import Headers

View File

@ -27,10 +27,9 @@ import datetime
import mimetypes
from uuid import uuid4
# from babel.dates import format_date
from flask import Blueprint, request, url_for
from flask_babel import format_date
from flask_login import current_user
from .cw_login import current_user
from . import constants, logger

View File

@ -36,7 +36,7 @@ from flask import (
redirect,
abort
)
from flask_login import current_user
from .cw_login import current_user
from werkzeug.datastructures import Headers
from sqlalchemy import func
from sqlalchemy.sql.expression import and_, or_
@ -44,7 +44,6 @@ from sqlalchemy.exc import StatementError
from sqlalchemy.sql import select
import requests
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
from . import isoLanguages
from .epub import get_epub_layout

View File

@ -65,12 +65,14 @@ from os import urandom
from functools import wraps
from flask import g, Blueprint, abort, request
from flask_login import login_user, current_user, login_required
from .cw_login import login_user, current_user
from flask_babel import gettext as _
from flask_limiter import RateLimitExceeded
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
from .render_template import render_title_template
from .usermanagement import user_login_required
log = logger.create()
@ -78,7 +80,7 @@ kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
@kobo_auth.route("/generate_auth_token/<int:user_id>")
@login_required
@user_login_required
def generate_auth_token(user_id):
warning = False
host_list = request.host.rsplit(':')
@ -120,7 +122,7 @@ def generate_auth_token(user_id):
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
@login_required
@user_login_required
def delete_auth_token(user_id):
# Invalidate any previously generated Kobo Auth token for this user
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\

View File

@ -17,11 +17,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from flask_login import current_user
from .cw_login import current_user
from . import ub
import datetime
from sqlalchemy.sql.expression import or_, and_, true
from sqlalchemy import exc
# from sqlalchemy import exc
# Add the current book id to kobo_synced_books table for current user, if entry is already present,

View File

@ -30,8 +30,9 @@ 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 oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
from flask_login import login_user, current_user, login_required
from .cw_login import login_user, current_user
from sqlalchemy.orm.exc import NoResultFound
from .usermanagement import user_login_required
from . import constants, logger, config, app, ub
@ -340,7 +341,7 @@ def github_login():
@oauth.route('/unlink/github', methods=["GET"])
@login_required
@user_login_required
def github_login_unlink():
return unlink_oauth(oauthblueprints[0]['id'])
@ -364,6 +365,6 @@ def google_login():
@oauth.route('/unlink/google', methods=["GET"])
@login_required
@user_login_required
def google_login_unlink():
return unlink_oauth(oauthblueprints[1]['id'])

View File

@ -25,14 +25,15 @@ import json
from urllib.parse import unquote_plus
from flask import Blueprint, request, render_template, make_response, abort, Response, g
from flask_login import current_user
from flask_babel import get_locale
from flask_babel import gettext as _
from sqlalchemy.sql.expression import func, text, or_, and_, true
from sqlalchemy.exc import InvalidRequestError, OperationalError
from . import logger, config, db, calibre_db, ub, isoLanguages, constants
from .usermanagement import requires_basic_auth_if_no_ano
from .usermanagement import requires_basic_auth_if_no_ano, auth
from .helper import get_download_link, get_book_cover
from .pagination import Pagination
from .web import render_read_books
@ -94,7 +95,7 @@ def feed_letter_books(book_id):
@opds.route("/opds/new")
@requires_basic_auth_if_no_ano
def feed_new():
if not current_user.check_visibility(constants.SIDEBAR_RECENT):
if not auth.current_user().check_visibility(constants.SIDEBAR_RECENT):
abort(404)
off = request.args.get("offset") or 0
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
@ -106,7 +107,7 @@ def feed_new():
@opds.route("/opds/discover")
@requires_basic_auth_if_no_ano
def feed_discover():
if not current_user.check_visibility(constants.SIDEBAR_RANDOM):
if not auth.current_user().check_visibility(constants.SIDEBAR_RANDOM):
abort(404)
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
@ -117,7 +118,7 @@ def feed_discover():
@opds.route("/opds/rated")
@requires_basic_auth_if_no_ano
def feed_best_rated():
if not current_user.check_visibility(constants.SIDEBAR_BEST_RATED):
if not auth.current_user().check_visibility(constants.SIDEBAR_BEST_RATED):
abort(404)
off = request.args.get("offset") or 0
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
@ -130,7 +131,7 @@ def feed_best_rated():
@opds.route("/opds/hot")
@requires_basic_auth_if_no_ano
def feed_hot():
if not current_user.check_visibility(constants.SIDEBAR_HOT):
if not auth.current_user().check_visibility(constants.SIDEBAR_HOT):
abort(404)
off = request.args.get("offset") or 0
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
@ -154,7 +155,7 @@ def feed_hot():
@opds.route("/opds/author")
@requires_basic_auth_if_no_ano
def feed_authorindex():
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
abort(404)
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
@ -162,7 +163,7 @@ def feed_authorindex():
@opds.route("/opds/author/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_author(book_id):
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
abort(404)
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
@ -185,7 +186,7 @@ def feed_author(book_id):
@opds.route("/opds/publisher")
@requires_basic_auth_if_no_ano
def feed_publisherindex():
if not current_user.check_visibility(constants.SIDEBAR_PUBLISHER):
if not auth.current_user().check_visibility(constants.SIDEBAR_PUBLISHER):
abort(404)
off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Publishers)\
@ -208,7 +209,7 @@ def feed_publisher(book_id):
@opds.route("/opds/category")
@requires_basic_auth_if_no_ano
def feed_categoryindex():
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
abort(404)
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
@ -216,7 +217,7 @@ def feed_categoryindex():
@opds.route("/opds/category/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_category(book_id):
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
abort(404)
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
@ -241,7 +242,7 @@ def feed_category(book_id):
@opds.route("/opds/series")
@requires_basic_auth_if_no_ano
def feed_seriesindex():
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
abort(404)
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
@ -249,7 +250,7 @@ def feed_seriesindex():
@opds.route("/opds/series/letter/<book_id>")
@requires_basic_auth_if_no_ano
def feed_letter_series(book_id):
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
abort(404)
off = request.args.get("offset") or 0
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
@ -280,7 +281,7 @@ def feed_series(book_id):
@opds.route("/opds/ratings")
@requires_basic_auth_if_no_ano
def feed_ratingindex():
if not current_user.check_visibility(constants.SIDEBAR_RATING):
if not auth.current_user().check_visibility(constants.SIDEBAR_RATING):
abort(404)
off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
@ -308,7 +309,7 @@ def feed_ratings(book_id):
@opds.route("/opds/formats")
@requires_basic_auth_if_no_ano
def feed_formatindex():
if not current_user.check_visibility(constants.SIDEBAR_FORMAT):
if not auth.current_user().check_visibility(constants.SIDEBAR_FORMAT):
abort(404)
off = request.args.get("offset") or 0
entries = calibre_db.session.query(db.Data).join(db.Books)\
@ -339,14 +340,14 @@ def feed_format(book_id):
@opds.route("/opds/language/")
@requires_basic_auth_if_no_ano
def feed_languagesindex():
if not current_user.check_visibility(constants.SIDEBAR_LANGUAGE):
if not auth.current_user().check_visibility(constants.SIDEBAR_LANGUAGE):
abort(404)
off = request.args.get("offset") or 0
if current_user.filter_language() == "all":
if auth.current_user().filter_language() == "all":
languages = calibre_db.speaking_language()
else:
languages = calibre_db.session.query(db.Languages).filter(
db.Languages.lang_code == current_user.filter_language()).all()
db.Languages.lang_code == auth.current_user().filter_language()).all()
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
len(languages))
@ -368,11 +369,11 @@ def feed_languages(book_id):
@opds.route("/opds/shelfindex")
@requires_basic_auth_if_no_ano
def feed_shelfindex():
if not (current_user.is_authenticated or g.allow_anonymous):
if not (auth.current_user().is_authenticated or g.allow_anonymous):
abort(404)
off = request.args.get("offset") or 0
shelf = 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()
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == auth.current_user().id)).order_by(ub.Shelf.name).all()
number = len(shelf)
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
number)
@ -382,14 +383,14 @@ def feed_shelfindex():
@opds.route("/opds/shelf/<int:book_id>")
@requires_basic_auth_if_no_ano
def feed_shelf(book_id):
if not (current_user.is_authenticated or g.allow_anonymous):
if not (auth.current_user().is_authenticated or g.allow_anonymous):
abort(404)
off = request.args.get("offset") or 0
if current_user.is_anonymous:
if auth.current_user().is_anonymous:
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
ub.Shelf.id == book_id).first()
else:
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(auth.current_user().id),
ub.Shelf.id == book_id),
and_(ub.Shelf.is_public == 1,
ub.Shelf.id == book_id))).first()
@ -422,7 +423,7 @@ def feed_shelf(book_id):
@opds.route("/opds/download/<book_id>/<book_format>/")
@requires_basic_auth_if_no_ano
def opds_download_link(book_id, book_format):
if not current_user.role_download():
if not auth.current_user().role_download():
return abort(403)
if "Kobo" in request.headers.get('User-Agent'):
client = "kobo"
@ -468,7 +469,7 @@ def feed_get_cover(book_id):
@opds.route("/opds/readbooks")
@requires_basic_auth_if_no_ano
def feed_read_books():
if not (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
return abort(403)
off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
@ -478,7 +479,7 @@ def feed_read_books():
@opds.route("/opds/unreadbooks")
@requires_basic_auth_if_no_ano
def feed_unread_books():
if not (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
return abort(403)
off = request.args.get("offset") or 0
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)

View File

@ -25,12 +25,13 @@ from datetime import datetime
from functools import wraps
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
from flask_login import login_required, current_user, login_user
from .cw_login import login_user, current_user
from flask_babel import gettext as _
from sqlalchemy.sql.expression import true
from . import config, logger, ub
from .render_template import render_title_template
from .usermanagement import user_login_required
remotelogin = Blueprint('remotelogin', __name__)
@ -65,7 +66,7 @@ def remote_login():
@remotelogin.route('/verify/<token>')
@remote_login_required
@login_required
@user_login_required
def verify_token(token):
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()

View File

@ -19,14 +19,13 @@
from flask import render_template, g, abort, request
from flask_babel import gettext as _
from werkzeug.local import LocalProxy
from flask_login import current_user
from .cw_login import current_user
from sqlalchemy.sql.expression import or_
from . import config, constants, logger, ub
from .ub import User
log = logger.create()
def get_sidebar_config(kwargs=None):

View File

@ -21,7 +21,7 @@ import datetime
from . import config, constants
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
from .tasks.database import TaskReconnectDatabase
from .tasks.tempFolder import TaskDeleteTempFolder
from .tasks.clean import TaskClean
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
from .services.worker import WorkerThread
from .tasks.metadata_backup import TaskBackupMetadata
@ -33,7 +33,7 @@ def get_scheduled_tasks(reconnect=True):
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
# Delete temp folder
tasks.append([lambda: TaskDeleteTempFolder(), 'delete temp', True])
tasks.append([lambda: TaskClean(), 'delete temp', True])
# Generate metadata.opf file for each changed book
if config.schedule_metadata_backup:
@ -94,7 +94,7 @@ def register_startup_tasks():
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
else:
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskDeleteTempFolder(), 'delete temp', True]])
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskClean(), 'delete temp', True]])
def should_task_be_running(start, duration):

View File

@ -19,7 +19,7 @@ from datetime import datetime
from flask import Blueprint, request, redirect, url_for, flash
from flask import session as flask_session
from flask_login import current_user
from .cw_login import current_user
from flask_babel import format_date
from flask_babel import gettext as _
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
@ -30,6 +30,7 @@ from .usermanagement import login_required_if_no_ano
from .render_template import render_title_template
from .pagination import Pagination
search = Blueprint('search', __name__)
log = logger.create()

View File

@ -24,14 +24,14 @@ import os
import sys
from flask import Blueprint, Response, request, url_for
from flask_login import current_user
from flask_login import login_required
from .cw_login import current_user
from flask_babel import get_locale
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.orm.attributes import flag_modified
from cps.services.Metadata import Metadata
from . import constants, logger, ub, web_server
from .usermanagement import user_login_required
# current_milli_time = lambda: int(round(time() * 1000))
@ -81,7 +81,7 @@ cl = list_classes(new_list)
@meta.route("/metadata/provider")
@login_required
@user_login_required
def metadata_provider():
active = current_user.view_settings.get("metadata", {})
provider = list()
@ -95,7 +95,7 @@ def metadata_provider():
@meta.route("/metadata/provider", methods=["POST"])
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
@login_required
@user_login_required
def metadata_change_active_provider(prov_name):
new_state = request.get_json()
active = current_user.view_settings.get("metadata", {})
@ -122,7 +122,7 @@ def metadata_change_active_provider(prov_name):
@meta.route("/metadata/search", methods=["POST"])
@login_required
@user_login_required
def metadata_search():
query = request.form.to_dict().get("query")
data = list()

View File

@ -25,13 +25,13 @@ from datetime import datetime
from flask import Blueprint, flash, redirect, request, url_for, abort
from flask_babel import gettext as _
from flask_login import current_user, login_required
from .cw_login import current_user
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.sql.expression import func, true
from . import calibre_db, config, db, logger, ub
from .render_template import render_title_template
from .usermanagement import login_required_if_no_ano
from .usermanagement import login_required_if_no_ano, user_login_required
log = logger.create()
@ -39,7 +39,7 @@ shelf = Blueprint('shelf', __name__)
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
@login_required
@user_login_required
def add_to_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
@ -103,7 +103,7 @@ def add_to_shelf(shelf_id, book_id):
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
@login_required
@user_login_required
def search_to_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf is None:
@ -155,7 +155,7 @@ def search_to_shelf(shelf_id):
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
@login_required
@user_login_required
def remove_from_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
@ -212,14 +212,14 @@ def remove_from_shelf(shelf_id, book_id):
@shelf.route("/shelf/create", methods=["GET", "POST"])
@login_required
@user_login_required
def create_shelf():
shelf = ub.Shelf()
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
@login_required
@user_login_required
def edit_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if not check_shelf_edit_permissions(shelf):
@ -229,7 +229,7 @@ def edit_shelf(shelf_id):
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
@login_required
@user_login_required
def delete_shelf(shelf_id):
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
try:
@ -259,7 +259,7 @@ def show_shelf(shelf_id, sort_param, page):
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
@login_required
@user_login_required
def order_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf and check_shelf_view_permissions(shelf):

View File

@ -1894,6 +1894,8 @@
width:100%;
height:100%;
margin:0;
top:0;
left:0;
}
.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) > .resizers{
@ -2646,6 +2648,10 @@
}
}
.pdfViewer.copyAll{
cursor:wait;
}
.pdfViewer .canvasWrapper{
overflow:hidden;
width:100%;
@ -3010,6 +3016,15 @@ body{
scrollbar-color:var(--scrollbar-color) var(--scrollbar-bg-color);
}
body.wait::before{
content:"";
position:fixed;
width:100%;
height:100%;
z-index:100000;
cursor:wait;
}
.hidden,
[hidden]{
display:none !important;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -142,7 +142,7 @@ function scrollIntoView(element, spot, scrollMatches = false) {
}
parent.scrollTop = offsetY;
}
function watchScroll(viewAreaElement, callback) {
function watchScroll(viewAreaElement, callback, abortSignal = undefined) {
const debounceScroll = function (evt) {
if (rAF) {
return;
@ -172,7 +172,13 @@ function watchScroll(viewAreaElement, callback) {
_eventHandler: debounceScroll
};
let rAF = null;
viewAreaElement.addEventListener("scroll", debounceScroll, true);
viewAreaElement.addEventListener("scroll", debounceScroll, {
useCapture: true,
signal: abortSignal
});
abortSignal?.addEventListener("abort", () => window.cancelAnimationFrame(rAF), {
once: true
});
return state;
}
function parseQueryString(query) {
@ -250,9 +256,8 @@ function approximateFraction(x) {
}
return result;
}
function roundToDivide(x, div) {
const r = x % div;
return r === 0 ? x : Math.round(x - r + div);
function floorToDivide(x, div) {
return x - x % div;
}
function getPageSizeInches({
view,
@ -738,6 +743,10 @@ const defaultOptions = {
value: "",
kind: OptionKind.API
},
enableHWA: {
value: true,
kind: OptionKind.API + OptionKind.VIEWER + OptionKind.PREFERENCE
},
enableXfa: {
value: true,
kind: OptionKind.API + OptionKind.PREFERENCE
@ -1357,9 +1366,18 @@ class EventBus {
}
}
}
class AutomationEventBus extends EventBus {
class FirefoxEventBus extends EventBus {
#externalServices;
#globalEventNames;
#isInAutomation;
constructor(globalEventNames, externalServices, isInAutomation) {
super();
this.#globalEventNames = globalEventNames;
this.#externalServices = externalServices;
this.#isInAutomation = isInAutomation;
}
dispatch(eventName, data) {
throw new Error("Not implemented: AutomationEventBus.dispatch");
throw new Error("Not implemented: FirefoxEventBus.dispatch");
}
}
@ -1384,6 +1402,10 @@ class BaseExternalServices {
throw new Error("Not implemented: updateEditorStates");
}
async getNimbusExperimentData() {}
async getGlobalEventNames() {
return null;
}
dispatchGlobalEvent(_event) {}
}
;// CONCATENATED MODULE: ./web/preferences.js
@ -1430,6 +1452,7 @@ class BasePreferences {
disableFontFace: false,
disableRange: false,
disableStream: false,
enableHWA: true,
enableXfa: true,
viewerCssTheme: 0
});
@ -2743,6 +2766,9 @@ class DOMLocalization extends Localization {
this.pauseObserving();
if (this.roots.size === 0) {
this.mutationObserver = null;
if (this.windowElement && this.pendingrAF) {
this.windowElement.cancelAnimationFrame(this.pendingrAF);
}
this.windowElement = null;
this.pendingrAF = null;
this.pendingElements.clear();
@ -2843,6 +2869,7 @@ class DOMLocalization extends Localization {
;// CONCATENATED MODULE: ./web/l10n.js
class L10n {
#dir;
#elements = new Set();
#lang;
#l10n;
constructor({
@ -2877,11 +2904,19 @@ class L10n {
return messages?.[0].value || fallback;
}
async translate(element) {
this.#elements.add(element);
try {
this.#l10n.connectRoot(element);
await this.#l10n.translateRoots();
} catch {}
}
async destroy() {
for (const element of this.#elements) {
this.#l10n.disconnectRoot(element);
}
this.#elements.clear();
this.#l10n.pauseObserving();
}
pause() {
this.#l10n.pauseObserving();
}
@ -2954,8 +2989,7 @@ class genericl10n_GenericL10n extends L10n {
const bundle = await this.#createBundle(lang, baseURL, paths);
if (bundle) {
yield bundle;
}
if (lang === "en-us") {
} else if (lang === "en-us") {
yield this.#createBundleFallback(lang);
}
}
@ -3625,13 +3659,6 @@ function download(blobUrl, filename) {
}
class DownloadManager {
#openBlobUrls = new WeakMap();
downloadUrl(url, filename, _options) {
if (!createValidAbsoluteUrl(url, "http://example.com")) {
console.error(`downloadUrl - not a valid URL: ${url}`);
return;
}
download(url + "#pdfjs.action=download", filename);
}
downloadData(data, filename, contentType) {
const blobUrl = URL.createObjectURL(new Blob([data], {
type: contentType
@ -3666,8 +3693,19 @@ class DownloadManager {
this.downloadData(data, filename, contentType);
return false;
}
download(blob, url, filename, _options) {
const blobUrl = URL.createObjectURL(blob);
download(data, url, filename, _options) {
let blobUrl;
if (data) {
blobUrl = URL.createObjectURL(new Blob([data], {
type: "application/pdf"
}));
} else {
if (!createValidAbsoluteUrl(url, "http://example.com")) {
console.error(`download - not a valid URL: ${url}`);
return;
}
blobUrl = url + "#pdfjs.action=download";
}
download(blobUrl, filename);
}
}
@ -5180,6 +5218,7 @@ class PDFFindController {
source: this,
state,
previous,
entireWord: this.#state?.entireWord ?? null,
matchesCount: this.#requestMatchesCount(),
rawQuery: this.#state?.query ?? null
});
@ -7711,7 +7750,8 @@ class PDFThumbnailView {
optionalContentConfigPromise,
linkService,
renderingQueue,
pageColors
pageColors,
enableHWA
}) {
this.id = id;
this.renderingId = "thumbnail" + id;
@ -7722,6 +7762,7 @@ class PDFThumbnailView {
this.pdfPageRotate = defaultViewport.rotation;
this._optionalContentConfigPromise = optionalContentConfigPromise || null;
this.pageColors = pageColors || null;
this.enableHWA = enableHWA || false;
this.eventBus = eventBus;
this.linkService = linkService;
this.renderingQueue = renderingQueue;
@ -7805,10 +7846,11 @@ class PDFThumbnailView {
}
this.resume = null;
}
#getPageDrawContext(upscaleFactor = 1) {
#getPageDrawContext(upscaleFactor = 1, enableHWA = this.enableHWA) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d", {
alpha: false
alpha: false,
willReadFrequently: !enableHWA
});
const outputScale = new OutputScale();
canvas.width = upscaleFactor * this.canvasWidth * outputScale.sx | 0;
@ -7927,7 +7969,7 @@ class PDFThumbnailView {
const {
ctx,
canvas
} = this.#getPageDrawContext();
} = this.#getPageDrawContext(1, true);
if (img.width <= 2 * canvas.width) {
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height);
return canvas;
@ -7974,14 +8016,17 @@ class PDFThumbnailViewer {
eventBus,
linkService,
renderingQueue,
pageColors
pageColors,
abortSignal,
enableHWA
}) {
this.container = container;
this.eventBus = eventBus;
this.linkService = linkService;
this.renderingQueue = renderingQueue;
this.pageColors = pageColors || null;
this.scroll = watchScroll(this.container, this.#scrollUpdated.bind(this));
this.enableHWA = enableHWA || false;
this.scroll = watchScroll(this.container, this.#scrollUpdated.bind(this), abortSignal);
this.#resetView();
}
#scrollUpdated() {
@ -8102,7 +8147,8 @@ class PDFThumbnailViewer {
optionalContentConfigPromise,
linkService: this.linkService,
renderingQueue: this.renderingQueue,
pageColors: this.pageColors
pageColors: this.pageColors,
enableHWA: this.enableHWA
});
this._thumbnails.push(thumbnail);
}
@ -8932,13 +8978,6 @@ class TextLayerBuilder {
this.div.tabIndex = 0;
this.div.className = "textLayer";
}
#finishRendering() {
this.#renderingDone = true;
const endOfContent = document.createElement("div");
endOfContent.className = "endOfContent";
this.div.append(endOfContent);
this.#bindMouse(endOfContent);
}
async render(viewport, textContentParams = null) {
if (this.#renderingDone && this.#textLayer) {
this.#textLayer.update({
@ -8964,7 +9003,11 @@ class TextLayerBuilder {
this.highlighter?.setTextMapping(textDivs, textContentItemsStr);
this.accessibilityManager?.setTextMapping(textDivs);
await this.#textLayer.render();
this.#finishRendering();
this.#renderingDone = true;
const endOfContent = document.createElement("div");
endOfContent.className = "endOfContent";
this.div.append(endOfContent);
this.#bindMouse(endOfContent);
this.#onAppend?.(this.div);
this.highlighter?.enable();
this.accessibilityManager?.enable();
@ -9097,6 +9140,7 @@ const DEFAULT_LAYER_PROPERTIES = null;
const LAYERS_ORDER = new Map([["canvasWrapper", 0], ["textLayer", 1], ["annotationLayer", 2], ["annotationEditorLayer", 3], ["xfaLayer", 3]]);
class PDFPageView {
#annotationMode = AnnotationMode.ENABLE_FORMS;
#enableHWA = false;
#hasRestrictedScaling = false;
#layerProperties = null;
#loadingId = null;
@ -9129,6 +9173,7 @@ class PDFPageView {
this.imageResourcesPath = options.imageResourcesPath || "";
this.maxCanvasPixels = options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels");
this.pageColors = options.pageColors || null;
this.#enableHWA = options.enableHWA || false;
this.eventBus = options.eventBus;
this.renderingQueue = options.renderingQueue;
this.l10n = options.l10n;
@ -9739,7 +9784,8 @@ class PDFPageView {
canvasWrapper.append(canvas);
this.canvas = canvas;
const ctx = canvas.getContext("2d", {
alpha: false
alpha: false,
willReadFrequently: !this.#enableHWA
});
const outputScale = this.outputScale = new OutputScale();
if (this.maxCanvasPixels === 0) {
@ -9760,13 +9806,13 @@ class PDFPageView {
}
const sfx = approximateFraction(outputScale.sx);
const sfy = approximateFraction(outputScale.sy);
canvas.width = roundToDivide(width * outputScale.sx, sfx[0]);
canvas.height = roundToDivide(height * outputScale.sy, sfy[0]);
canvas.width = floorToDivide(width * outputScale.sx, sfx[0]);
canvas.height = floorToDivide(height * outputScale.sy, sfy[0]);
const {
style
} = canvas;
style.width = roundToDivide(width, sfx[1]) + "px";
style.height = roundToDivide(height, sfy[1]) + "px";
style.width = floorToDivide(width, sfx[1]) + "px";
style.height = floorToDivide(height, sfy[1]) + "px";
this.#viewportMap.set(canvas, viewport);
const transform = outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null;
const renderContext = {
@ -9870,8 +9916,8 @@ class PDFPageView {
const DEFAULT_CACHE_SIZE = 10;
const PagesCountLimit = {
FORCE_SCROLL_MODE_PAGE: 15000,
FORCE_LAZY_PAGE_INIT: 7500,
FORCE_SCROLL_MODE_PAGE: 10000,
FORCE_LAZY_PAGE_INIT: 5000,
PAUSE_EAGER_PAGE_INIT: 250
};
function isValidAnnotationEditorMode(mode) {
@ -9933,6 +9979,7 @@ class PDFViewer {
#annotationEditorUIManager = null;
#annotationMode = AnnotationMode.ENABLE_FORMS;
#containerTopLeft = null;
#enableHWA = false;
#enableHighlightFloatingButton = false;
#enablePermissions = false;
#eventAbortController = null;
@ -9946,7 +9993,7 @@ class PDFViewer {
#scaleTimeoutId = null;
#textLayerMode = TextLayerMode.ENABLE;
constructor(options) {
const viewerVersion = "4.3.136";
const viewerVersion = "4.4.168";
if (version !== viewerVersion) {
throw new Error(`The API version "${version}" does not match the Viewer version "${viewerVersion}".`);
}
@ -9982,6 +10029,7 @@ class PDFViewer {
this.#enablePermissions = options.enablePermissions || false;
this.pageColors = options.pageColors || null;
this.#mlManager = options.mlManager || null;
this.#enableHWA = options.enableHWA || false;
this.defaultRenderingQueue = !options.renderingQueue;
if (this.defaultRenderingQueue) {
this.renderingQueue = new PDFRenderingQueue();
@ -9989,7 +10037,16 @@ class PDFViewer {
} else {
this.renderingQueue = options.renderingQueue;
}
this.scroll = watchScroll(this.container, this._scrollUpdate.bind(this));
const {
abortSignal
} = options;
abortSignal?.addEventListener("abort", () => {
this.#resizeObserver.disconnect();
this.#resizeObserver = null;
}, {
once: true
});
this.scroll = watchScroll(this.container, this._scrollUpdate.bind(this), abortSignal);
this.presentationModeState = PresentationModeState.UNKNOWN;
this._resetView();
if (this.removePageBorders) {
@ -10254,10 +10311,14 @@ class PDFViewer {
return;
}
this.#getAllTextInProgress = true;
const savedCursor = this.container.style.cursor;
this.container.style.cursor = "wait";
const interruptCopy = ev => this.#interruptCopyCondition = ev.key === "Escape";
window.addEventListener("keydown", interruptCopy);
const {
classList
} = this.viewer;
classList.add("copyAll");
const ac = new AbortController();
window.addEventListener("keydown", ev => this.#interruptCopyCondition = ev.key === "Escape", {
signal: ac.signal
});
this.getAllText().then(async text => {
if (text !== null) {
await navigator.clipboard.writeText(text);
@ -10267,8 +10328,8 @@ class PDFViewer {
}).finally(() => {
this.#getAllTextInProgress = false;
this.#interruptCopyCondition = false;
window.removeEventListener("keydown", interruptCopy);
this.container.style.cursor = savedCursor;
ac.abort();
classList.remove("copyAll");
});
event.preventDefault();
event.stopPropagation();
@ -10401,7 +10462,8 @@ class PDFViewer {
maxCanvasPixels: this.maxCanvasPixels,
pageColors,
l10n: this.l10n,
layerProperties: this._layerProperties
layerProperties: this._layerProperties,
enableHWA: this.#enableHWA
});
this._pages.push(pageView);
}
@ -11549,26 +11611,26 @@ class SecondaryToolbar {
eventDetails
} of buttons) {
if ( element !== null ) {
element.addEventListener("click", evt => {
if (eventName !== null) {
eventBus.dispatch(eventName, {
source: this,
...eventDetails
});
element.addEventListener("click", evt => {
if (eventName !== null) {
eventBus.dispatch(eventName, {
source: this,
...eventDetails
});
}
if (close) {
this.close();
}
eventBus.dispatch("reporttelemetry", {
source: this,
details: {
type: "buttons",
data: {
id: element.id
}
if (close) {
this.close();
}
eventBus.dispatch("reporttelemetry", {
source: this,
details: {
type: "buttons",
data: {
id: element.id
}
}
});
}
});
});
}
}
eventBus._on("cursortoolchanged", this.#cursorToolChanged.bind(this));
@ -12059,9 +12121,11 @@ const PDFViewerApplication = {
isViewerEmbedded: window.parent !== window,
url: "",
baseUrl: "",
_allowedGlobalEventsPromise: null,
_downloadUrl: "",
_eventBusAbortController: null,
_windowAbortController: null,
_globalAbortController: new AbortController(),
documentInfo: null,
metadata: null,
_contentDispositionFilename: null,
@ -12202,7 +12266,8 @@ const PDFViewerApplication = {
externalServices,
l10n
} = this;
const eventBus = AppOptions.get("isInAutomation") ? new AutomationEventBus() : new EventBus();
let eventBus;
eventBus = new EventBus();
this.eventBus = eventBus;
this.overlayManager = new OverlayManager();
const pdfRenderingQueue = new PDFRenderingQueue();
@ -12236,6 +12301,7 @@ const PDFViewerApplication = {
foreground: AppOptions.get("pageColorsForeground")
} : null;
const altTextManager = appConfig.altTextDialog ? new AltTextManager(appConfig.altTextDialog, container, this.overlayManager, eventBus) : null;
const enableHWA = AppOptions.get("enableHWA");
const pdfViewer = new PDFViewer({
container,
viewer,
@ -12257,7 +12323,9 @@ const PDFViewerApplication = {
maxCanvasPixels: AppOptions.get("maxCanvasPixels"),
enablePermissions: AppOptions.get("enablePermissions"),
pageColors,
mlManager: this.mlManager
mlManager: this.mlManager,
abortSignal: this._globalAbortController.signal,
enableHWA
});
this.pdfViewer = pdfViewer;
pdfRenderingQueue.setViewer(pdfViewer);
@ -12269,7 +12337,9 @@ const PDFViewerApplication = {
eventBus,
renderingQueue: pdfRenderingQueue,
linkService: pdfLinkService,
pageColors
pageColors,
abortSignal: this._globalAbortController.signal,
enableHWA
});
pdfRenderingQueue.setThumbnailViewer(this.pdfThumbnailViewer);
}
@ -12378,7 +12448,7 @@ const PDFViewerApplication = {
const params = parseQueryString(queryString);
file = params.get("file") ?? AppOptions.get("defaultUrl");
validateFileURL(file);
/* const fileInput = this._openFileInput = document.createElement("input");
/*const fileInput = this._openFileInput = document.createElement("input");
fileInput.id = "fileInput";
fileInput.hidden = true;
fileInput.type = "file";
@ -12395,24 +12465,28 @@ const PDFViewerApplication = {
source: this,
fileInput: evt.target
});
});*/
});
appConfig.mainContainer.addEventListener("dragover", function (evt) {
evt.preventDefault();
evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === "copy" ? "copy" : "move";
for (const item of evt.dataTransfer.items) {
if (item.type === "application/pdf") {
evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === "copy" ? "copy" : "move";
evt.preventDefault();
evt.stopPropagation();
return;
}
}
});
appConfig.mainContainer.addEventListener("drop", function (evt) {
evt.preventDefault();
const {
files
} = evt.dataTransfer;
if (!files || files.length === 0) {
if (evt.dataTransfer.files?.[0].type !== "application/pdf") {
return;
}
/*eventBus.dispatch("fileinputchange", {
evt.preventDefault();
evt.stopPropagation();
eventBus.dispatch("fileinputchange", {
source: this,
fileInput: evt.dataTransfer
});*/
});
});
});*/
if (!AppOptions.get("supportsDocumentFonts")) {
AppOptions.set("disableFontFace", true);
this.l10n.get("pdfjs-web-fonts-disabled").then(msg => {
@ -12647,25 +12721,14 @@ const PDFViewerApplication = {
});
});
},
_ensureDownloadComplete() {
if (this.pdfDocument && this.downloadComplete) {
return;
}
throw new Error("PDF document not downloaded.");
},
async download(options = {}) {
const url = this._downloadUrl,
filename = this._docFilename;
let data;
try {
this._ensureDownloadComplete();
const data = await this.pdfDocument.getData();
const blob = new Blob([data], {
type: "application/pdf"
});
await this.downloadManager.download(blob, url, filename, options);
} catch {
await this.downloadManager.downloadUrl(url, filename, options);
}
if (this.downloadComplete) {
data = await this.pdfDocument.getData();
}
} catch {}
this.downloadManager.download(data, this._downloadUrl, this._docFilename, options);
},
async save(options = {}) {
if (this._saveInProgress) {
@ -12673,15 +12736,9 @@ const PDFViewerApplication = {
}
this._saveInProgress = true;
await this.pdfScriptingManager.dispatchWillSave();
const url = this._downloadUrl,
filename = this._docFilename;
try {
this._ensureDownloadComplete();
const data = await this.pdfDocument.saveDocument();
const blob = new Blob([data], {
type: "application/pdf"
});
await this.downloadManager.download(blob, url, filename, options);
this.downloadManager.download(data, this._downloadUrl, this._docFilename, options);
} catch (reason) {
console.error(`Error when saving the document: ${reason.message}`);
await this.download(options);
@ -12699,12 +12756,13 @@ const PDFViewerApplication = {
});
}
},
downloadOrSave(options = {}) {
if (this.pdfDocument?.annotationStorage.size > 0) {
this.save(options);
} else {
this.download(options);
}
async downloadOrSave(options = {}) {
const {
classList
} = this.appConfig.appContainer;
classList.add("wait");
await (this.pdfDocument?.annotationStorage.size > 0 ? this.save(options) : this.download(options));
classList.remove("wait");
},
async _documentError(key, moreInfo = null) {
this._unblockDocumentLoadEvent();
@ -13471,6 +13529,14 @@ const PDFViewerApplication = {
this._windowAbortController?.abort();
this._windowAbortController = null;
},
async testingClose() {
this.unbindEvents();
this.unbindWindowEvents();
this._globalAbortController?.abort();
this._globalAbortController = null;
this.findBar?.close();
await Promise.all([this.l10n?.destroy(), this.close()]);
},
_accumulateTicks(ticks, prop) {
if (this[prop] > 0 && ticks < 0 || this[prop] < 0 && ticks > 0) {
this[prop] = 0;
@ -13664,7 +13730,7 @@ function webViewerHashchange(evt) {
}
}
{
var webViewerFileInputChange = function (evt) {
/*var webViewerFileInputChange = function (evt) {
if (PDFViewerApplication.pdfViewer?.isInPresentationMode) {
return;
}
@ -13674,7 +13740,7 @@ function webViewerHashchange(evt) {
originalUrl: file.name
});
};
/*var webViewerOpenFile = function (evt) {
var webViewerOpenFile = function (evt) {
PDFViewerApplication._openFileInput?.click();
};*/
}
@ -13768,6 +13834,7 @@ function webViewerUpdateFindMatchesCount({
function webViewerUpdateFindControlState({
state,
previous,
entireWord,
matchesCount,
rawQuery
}) {
@ -13775,6 +13842,7 @@ function webViewerUpdateFindControlState({
PDFViewerApplication.externalServices.updateFindControlState({
result: state,
findPrevious: previous,
entireWord,
matchesCount,
rawQuery
});
@ -14066,14 +14134,14 @@ function webViewerKeyDown(evt) {
});
handled = true;
break;
/*case 79:
case 79:
{
eventBus.dispatch("openfile", {
source: window
});
handled = true;
}
break;*/
break;
}
}
if (cmd === 3 || cmd === 10) {
@ -14273,8 +14341,8 @@ function webViewerReportTelemetry({
const pdfjsVersion = "4.3.136";
const pdfjsBuild = "0cec64437";
const pdfjsVersion = "4.4.168";
const pdfjsBuild = "19fbc8998";
const AppConstants = {
LinkTarget: LinkTarget,
RenderingStates: RenderingStates,

View File

@ -49,12 +49,6 @@ pdfjs-download-button =
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-download-button-label = Pellgargañ
pdfjs-bookmark-button-label = Pajenn a-vremañ
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = Digeriñ en arload
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = Digeriñ en arload
## Secondary toolbar and context menu
@ -214,6 +208,7 @@ pdfjs-find-next-button =
pdfjs-find-next-button-label = War-lerc'h
pdfjs-find-highlight-checkbox = Usskediñ pep tra
pdfjs-find-match-case-checkbox-label = Teurel evezh ouzh ar pennlizherennoù
pdfjs-find-match-diacritics-checkbox-label = Doujañ dan tiredoù
pdfjs-find-entire-word-checkbox-label = Gerioù a-bezh
pdfjs-find-reached-top = Tizhet eo bet derou ar bajenn, kenderc'hel diouzh an diaz
pdfjs-find-reached-bottom = Tizhet eo bet dibenn ar bajenn, kenderc'hel diouzh ar c'hrec'h
@ -311,3 +306,7 @@ pdfjs-editor-alt-text-save-button = Enrollañ
## Color picker
## Show all highlights
## This is a toggle button to show/hide all the highlights.

View File

@ -51,12 +51,6 @@ pdfjs-download-button-label = Sækja
pdfjs-bookmark-button =
.title = Núverandi síða (Skoða vefslóð frá núverandi síðu)
pdfjs-bookmark-button-label = Núverandi síða
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = Opna í smáforriti
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = Opna í smáforriti
## Secondary toolbar and context menu
@ -284,7 +278,7 @@ pdfjs-text-annotation-type =
## Password
pdfjs-password-label = Sláðu inn lykilorð til að opna þessa PDF skrá.
pdfjs-password-label = Settu inn lykilorð til að opna þessa PDF-skrá.
pdfjs-password-invalid = Ógilt lykilorð. Reyndu aftur.
pdfjs-password-ok-button = Í lagi
pdfjs-password-cancel-button = Hætta við
@ -304,8 +298,6 @@ pdfjs-editor-stamp-button-label = Bæta við eða breyta myndum
pdfjs-editor-highlight-button =
.title = Áherslulita
pdfjs-editor-highlight-button-label = Áherslulita
pdfjs-highlight-floating-button =
.title = Áherslulita
pdfjs-highlight-floating-button1 =
.title = Áherslulita
.aria-label = Áherslulita

View File

@ -279,7 +279,7 @@ pdfjs-text-annotation-type =
## Password
pdfjs-password-label = この PDF ファイルを開くためのパスワードを入力してください。
pdfjs-password-invalid = 無効なパスワードです。もう一度やり直してください。
pdfjs-password-invalid = パスワードが正しくありません。もう一度試してください。
pdfjs-password-ok-button = OK
pdfjs-password-cancel-button = キャンセル
pdfjs-web-fonts-disabled = ウェブフォントが無効になっています: 埋め込まれた PDF のフォントを使用できません。
@ -298,8 +298,6 @@ pdfjs-editor-stamp-button-label = 画像を追加または編集
pdfjs-editor-highlight-button =
.title = 強調します
pdfjs-editor-highlight-button-label = 強調
pdfjs-highlight-floating-button =
.title = 強調
pdfjs-highlight-floating-button1 =
.title = 強調
.aria-label = 強調します

View File

@ -51,12 +51,6 @@ pdfjs-download-button-label = Sader
pdfjs-bookmark-button =
.title = Asebter amiran (Sken-d tansa URL seg usebter amiran)
pdfjs-bookmark-button-label = Asebter amiran
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = Ldi deg usnas
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = Ldi deg usnas
## Secondary toolbar and context menu
@ -301,8 +295,27 @@ pdfjs-editor-ink-button-label = Suneɣ
pdfjs-editor-stamp-button =
.title = Rnu neɣ ẓreg tugniwin
pdfjs-editor-stamp-button-label = Rnu neɣ ẓreg tugniwin
pdfjs-editor-remove-button =
.title = Kkes
pdfjs-editor-highlight-button =
.title = Derrer
pdfjs-editor-highlight-button-label = Derrer
pdfjs-highlight-floating-button1 =
.title = Derrer
.aria-label = Derrer
pdfjs-highlight-floating-button-label = Derrer
## Remove button for the various kind of editor.
pdfjs-editor-remove-ink-button =
.title = Kkes asuneɣ
pdfjs-editor-remove-freetext-button =
.title = Kkes aḍris
pdfjs-editor-remove-stamp-button =
.title = Kkes tugna
pdfjs-editor-remove-highlight-button =
.title = Kkes aderrer
##
# Editor Parameters
pdfjs-editor-free-text-color-input = Initen
pdfjs-editor-free-text-size-input = Teɣzi
@ -312,6 +325,8 @@ pdfjs-editor-ink-opacity-input = Tebrek
pdfjs-editor-stamp-add-image-button =
.title = Rnu tawlaft
pdfjs-editor-stamp-add-image-button-label = Rnu tawlaft
# This refers to the thickness of the line used for free highlighting (not bound to text)
pdfjs-editor-free-highlight-thickness-input = Tuzert
pdfjs-free-text =
.aria-label = Amaẓrag n uḍris
pdfjs-free-text-default-content = Bdu tira...
@ -335,3 +350,37 @@ pdfjs-editor-alt-text-decorative-tooltip = Yettwacreḍ d adlag
## Editor resizers
## This is used in an aria label to help to understand the role of the resizer.
pdfjs-editor-resizer-label-top-left = Tiɣmert n ufella n zelmeḍ — semsawi teɣzi
pdfjs-editor-resizer-label-top-middle = Talemmat n ufella — semsawi teɣzi
pdfjs-editor-resizer-label-top-right = Tiɣmert n ufella n yeffus — semsawi teɣzi
pdfjs-editor-resizer-label-middle-right = Talemmast tayeffust — semsawi teɣzi
pdfjs-editor-resizer-label-bottom-right = Tiɣmert n wadda n yeffus — semsawi teɣzi
pdfjs-editor-resizer-label-bottom-middle = Talemmat n wadda — semsawi teɣzi
pdfjs-editor-resizer-label-bottom-left = Tiɣmert n wadda n zelmeḍ — semsawi teɣzi
pdfjs-editor-resizer-label-middle-left = Talemmast tazelmdaḍt — semsawi teɣzi
## Color picker
# This means "Color used to highlight text"
pdfjs-editor-highlight-colorpicker-label = Ini n uderrer
pdfjs-editor-colorpicker-button =
.title = Senfel ini
pdfjs-editor-colorpicker-dropdown =
.aria-label = Afran n yiniten
pdfjs-editor-colorpicker-yellow =
.title = Awraɣ
pdfjs-editor-colorpicker-green =
.title = Azegzaw
pdfjs-editor-colorpicker-blue =
.title = Amidadi
pdfjs-editor-colorpicker-pink =
.title = Axuxi
pdfjs-editor-colorpicker-red =
.title = Azggaɣ
## Show all highlights
## This is a toggle button to show/hide all the highlights.
pdfjs-editor-highlight-show-all-button-label = Sken akk
pdfjs-editor-highlight-show-all-button =
.title = Sken akk

View File

@ -51,12 +51,6 @@ pdfjs-download-button-label = Last ned
pdfjs-bookmark-button =
.title = Gjeldande side (sjå URL frå gjeldande side)
pdfjs-bookmark-button-label = Gjeldande side
# Used in Firefox for Android.
pdfjs-open-in-app-button =
.title = Opne i app
# Used in Firefox for Android.
# Length of the translation matters since we are in a mobile context, with limited screen estate.
pdfjs-open-in-app-button-label = Opne i app
## Secondary toolbar and context menu
@ -301,9 +295,24 @@ pdfjs-editor-ink-button-label = Teikne
pdfjs-editor-stamp-button =
.title = Legg til eller rediger bilde
pdfjs-editor-stamp-button-label = Legg til eller rediger bilde
pdfjs-editor-highlight-button =
.title = Markere
pdfjs-editor-highlight-button-label = Markere
pdfjs-highlight-floating-button1 =
.title = Markere
.aria-label = Markere
pdfjs-highlight-floating-button-label = Markere
## Remove button for the various kind of editor.
pdfjs-editor-remove-ink-button =
.title = Fjern teikninga
pdfjs-editor-remove-freetext-button =
.title = Fjern tekst
pdfjs-editor-remove-stamp-button =
.title = Fjern bildet
pdfjs-editor-remove-highlight-button =
.title = Fjern utheving
##
@ -316,6 +325,10 @@ pdfjs-editor-ink-opacity-input = Ugjennomskinleg
pdfjs-editor-stamp-add-image-button =
.title = Legg til bilde
pdfjs-editor-stamp-add-image-button-label = Legg til bilde
# This refers to the thickness of the line used for free highlighting (not bound to text)
pdfjs-editor-free-highlight-thickness-input = Tjukkleik
pdfjs-editor-free-highlight-thickness-title =
.title = Endre tjukn når du markerer andre element enn tekst
pdfjs-free-text =
.aria-label = Tekstredigering
pdfjs-free-text-default-content = Byrje å skrive…
@ -345,9 +358,23 @@ pdfjs-editor-alt-text-textarea =
## Editor resizers
## This is used in an aria label to help to understand the role of the resizer.
pdfjs-editor-resizer-label-top-left = Øvste venstre hjørne endre størrelse
pdfjs-editor-resizer-label-top-middle = Øvst i midten — endre størrelse
pdfjs-editor-resizer-label-top-right = Øvste høgre hjørne endre størrelse
pdfjs-editor-resizer-label-middle-right = Midt til høgre endre størrelse
pdfjs-editor-resizer-label-bottom-right = Nedste høgre hjørne endre størrelse
pdfjs-editor-resizer-label-bottom-middle = Nedst i midten — endre størrelse
pdfjs-editor-resizer-label-bottom-left = Nedste venstre hjørne endre størrelse
pdfjs-editor-resizer-label-middle-left = Midt til venstre — endre størrelse
## Color picker
# This means "Color used to highlight text"
pdfjs-editor-highlight-colorpicker-label = Uthevingsfarge
pdfjs-editor-colorpicker-button =
.title = Endre farge
pdfjs-editor-colorpicker-dropdown =
.aria-label = Fargeval
pdfjs-editor-colorpicker-yellow =
.title = Gul
pdfjs-editor-colorpicker-green =
@ -358,3 +385,10 @@ pdfjs-editor-colorpicker-pink =
.title = Rosa
pdfjs-editor-colorpicker-red =
.title = Raud
## Show all highlights
## This is a toggle button to show/hide all the highlights.
pdfjs-editor-highlight-show-all-button-label = Vis alle
pdfjs-editor-highlight-show-all-button =
.title = Vis alle

View File

@ -302,6 +302,10 @@ pdfjs-editor-stamp-button-label = Dodajanje ali urejanje slik
pdfjs-editor-highlight-button =
.title = Označevalnik
pdfjs-editor-highlight-button-label = Označevalnik
pdfjs-highlight-floating-button1 =
.title = Označi
.aria-label = Označi
pdfjs-highlight-floating-button-label = Označi
## Remove button for the various kind of editor.

View File

@ -16,31 +16,48 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from urllib.request import urlopen
import datetime
from flask_babel import lazy_gettext as N_
from sqlalchemy.sql.expression import or_
from cps import logger, file_helper
from cps import logger, file_helper, ub
from cps.services.worker import CalibreTask
class TaskDeleteTempFolder(CalibreTask):
class TaskClean(CalibreTask):
def __init__(self, task_message=N_('Delete temp folder contents')):
super(TaskDeleteTempFolder, self).__init__(task_message)
super(TaskClean, self).__init__(task_message)
self.log = logger.create()
self.app_db_session = ub.get_new_session_instance()
def run(self, worker_thread):
# delete temp folder
try:
file_helper.del_temp_dir()
except FileNotFoundError:
pass
except (PermissionError, OSError) as e:
self.log.error("Error deleting temp folder: {}".format(e))
# delete expired session keys
self.log.debug("Deleted expired session_keys" )
expiry = int(datetime.datetime.now().timestamp())
try:
self.app_db_session.query(ub.User_Sessions).filter(or_(ub.User_Sessions.expiry < expiry,
ub.User_Sessions.expiry == None)).delete()
self.app_db_session.commit()
except Exception as ex:
self.log.debug('Error deleting expired session keys: ' + str(ex))
self._handleError('Error deleting expired session keys: ' + str(ex))
self.app_db_session.rollback()
return
self._handleSuccess()
self.app_db_session.remove()
@property
def name(self):
return "Delete Temp Folder"
return "Clean up"
@property
def is_cancellable(self):

View File

@ -17,7 +17,7 @@
from markupsafe import escape
from flask import Blueprint, jsonify
from flask_login import login_required, current_user
from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import format_datetime
from babel.units import format_unit
@ -26,6 +26,7 @@ from . import logger
from .render_template import render_title_template
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \
STAT_CANCELLED
from .usermanagement import user_login_required
tasks = Blueprint('tasks', __name__)
@ -33,14 +34,14 @@ log = logger.create()
@tasks.route("/ajax/emailstat")
@login_required
@user_login_required
def get_email_status_json():
tasks = WorkerThread.get_instance().tasks
return jsonify(render_task_status(tasks))
@tasks.route("/tasks")
@login_required
@user_login_required
def get_tasks_status():
# if current user admin, show all email, otherwise only own emails
return render_title_template('tasks.html', title=_("Tasks"), page="tasks")

248
cps/ub.py
View File

@ -26,8 +26,8 @@ import uuid
from flask import session as flask_session
from binascii import hexlify
from flask_login import AnonymousUserMixin, current_user
from flask_login import user_logged_in
from .cw_login import AnonymousUserMixin, current_user
from .cw_login import user_logged_in
try:
from flask_dance.consumer.backend.sqla import OAuthConsumerMixin
@ -71,17 +71,19 @@ def signal_store_user_session(object, user):
def store_user_session():
if flask_session.get('user_id', ""):
flask_session['_user_id'] = flask_session.get('user_id', "")
_user = flask_session.get('_user_id', "")
_id = flask_session.get('_id', "")
_random = flask_session.get('_random', "")
if flask_session.get('_user_id', ""):
try:
if not check_user_session(flask_session.get('_user_id', ""), flask_session.get('_id', "")):
user_session = User_Sessions(flask_session.get('_user_id', ""), flask_session.get('_id', ""))
if not check_user_session(_user, _id, _random):
expiry = int((datetime.datetime.now() + datetime.timedelta(days=31)).timestamp())
user_session = User_Sessions(_user, _id, _random, expiry)
session.add(user_session)
session.commit()
log.debug("Login and store session : " + flask_session.get('_id', ""))
log.debug("Login and store session : " + _id)
else:
log.debug("Found stored session: " + flask_session.get('_id', ""))
log.debug("Found stored session: " + _id)
except (exc.OperationalError, exc.InvalidRequestError) as e:
session.rollback()
log.exception(e)
@ -100,13 +102,23 @@ def delete_user_session(user_id, session_key):
log.exception(ex)
def check_user_session(user_id, session_key):
def check_user_session(user_id, session_key, random):
try:
return bool(session.query(User_Sessions).filter(User_Sessions.user_id==user_id,
User_Sessions.session_key==session_key).one_or_none())
found = session.query(User_Sessions).filter(User_Sessions.user_id==user_id,
User_Sessions.session_key==session_key,
User_Sessions.random == random,
).one_or_none()
if found is not None:
new_expiry = int((datetime.datetime.now() + datetime.timedelta(days=31)).timestamp())
if new_expiry - found.expiry > 86400:
found.expiry = new_expiry
session.merge(found)
session.commit()
return bool(found)
except (exc.OperationalError, exc.InvalidRequestError) as e:
session.rollback()
log.exception(e)
return False
user_logged_in.connect(signal_store_user_session)
@ -335,11 +347,16 @@ class User_Sessions(Base):
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('user.id'))
session_key = Column(String, default="")
random = Column(String, default="")
expiry = Column(Integer)
def __init__(self, user_id, session_key):
def __init__(self, user_id, session_key, random, expiry):
super().__init__()
self.user_id = user_id
self.session_key = session_key
self.random = random
self.expiry = expiry
# Baseclass representing Shelfs in calibre-web in app.db
@ -552,39 +569,14 @@ class Thumbnail(Base):
# Add missing tables during migration of database
def add_missing_tables(engine, _session):
if not engine.dialect.has_table(engine.connect(), "book_read_link"):
ReadBook.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "bookmark"):
Bookmark.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "kobo_reading_state"):
KoboReadingState.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "kobo_bookmark"):
KoboBookmark.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "kobo_statistics"):
KoboStatistics.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "archived_book"):
ArchivedBook.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "thumbnail"):
Thumbnail.__table__.create(bind=engine)
if not engine.dialect.has_table(engine.connect(), "registration"):
Registration.__table__.create(bind=engine)
with engine.connect() as conn:
trans = conn.begin()
conn.execute("insert into registration (domain, allow) values('%.%',1)")
trans.commit()
# migrate all settings missing in registration table
def migrate_registration_table(engine, _session):
try:
_session.query(exists().where(Registration.allow)).scalar()
_session.commit()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE registration ADD column 'allow' INTEGER"))
conn.execute(text("update registration set 'allow' = 1"))
trans.commit()
try:
# Handle table exists, but no content
cnt = _session.query(Registration).count()
@ -598,190 +590,38 @@ def migrate_registration_table(engine, _session):
sys.exit(2)
# Remove login capability of user Guest
def migrate_guest_password(engine):
def migrate_user_session_table(engine, _session):
try:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("UPDATE user SET password='' where name = 'Guest' and password !=''"))
trans.commit()
except exc.OperationalError:
print('Settings database is not writeable. Exiting...')
sys.exit(2)
def migrate_shelfs(engine, _session):
try:
_session.query(exists().where(Shelf.uuid)).scalar()
except exc.OperationalError:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE shelf ADD column 'uuid' STRING"))
conn.execute(text("ALTER TABLE shelf ADD column 'created' DATETIME"))
conn.execute(text("ALTER TABLE shelf ADD column 'last_modified' DATETIME"))
conn.execute(text("ALTER TABLE book_shelf_link ADD column 'date_added' DATETIME"))
conn.execute(text("ALTER TABLE shelf ADD column 'kobo_sync' BOOLEAN DEFAULT false"))
trans.commit()
for shelf in _session.query(Shelf).all():
shelf.uuid = str(uuid.uuid4())
shelf.created = datetime.datetime.now()
shelf.last_modified = datetime.datetime.now()
for book_shelf in _session.query(BookShelf).all():
book_shelf.date_added = datetime.datetime.now()
_session.commit()
try:
_session.query(exists().where(Shelf.kobo_sync)).scalar()
except exc.OperationalError:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE shelf ADD column 'kobo_sync' BOOLEAN DEFAULT false"))
trans.commit()
try:
_session.query(exists().where(BookShelf.order)).scalar()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE book_shelf_link ADD column 'order' INTEGER DEFAULT 1"))
trans.commit()
def migrate_readBook(engine, _session):
try:
_session.query(exists().where(ReadBook.read_status)).scalar()
except exc.OperationalError:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE book_read_link ADD column 'read_status' INTEGER DEFAULT 0"))
conn.execute(text("UPDATE book_read_link SET 'read_status' = 1 WHERE is_read"))
conn.execute(text("ALTER TABLE book_read_link ADD column 'last_modified' DATETIME"))
conn.execute(text("ALTER TABLE book_read_link ADD column 'last_time_started_reading' DATETIME"))
conn.execute(text("ALTER TABLE book_read_link ADD column 'times_started_reading' INTEGER DEFAULT 0"))
trans.commit()
test = _session.query(ReadBook).filter(ReadBook.last_modified == None).all()
for book in test:
book.last_modified = datetime.datetime.utcnow()
_session.commit()
def migrate_remoteAuthToken(engine, _session):
try:
_session.query(exists().where(RemoteAuthToken.token_type)).scalar()
_session.query(exists().where(User_Sessions.random)).scalar()
_session.commit()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE remote_auth_token ADD column 'token_type' INTEGER DEFAULT 0"))
conn.execute(text("update remote_auth_token set 'token_type' = 0"))
conn.execute(text("ALTER TABLE user_session ADD column 'random' String"))
conn.execute(text("ALTER TABLE user_session ADD column 'expiry' Integer"))
trans.commit()
# Migrate database to current version, has to be updated after every database change. Currently migration from
# everywhere to current should work. Migration is done by checking if relevant columns are existing, and than adding
# rows with SQL commands
# maybe 4/5 versions back to current should work.
# Migration is done by checking if relevant columns are existing, and then adding rows with SQL commands
def migrate_Database(_session):
engine = _session.bind
add_missing_tables(engine, _session)
migrate_registration_table(engine, _session)
migrate_readBook(engine, _session)
migrate_remoteAuthToken(engine, _session)
migrate_shelfs(engine, _session)
try:
create = False
_session.query(exists().where(User.sidebar_view)).scalar()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE user ADD column `sidebar_view` Integer DEFAULT 1"))
trans.commit()
create = True
try:
if create:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("SELECT language_books FROM user"))
trans.commit()
except exc.OperationalError:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("UPDATE user SET 'sidebar_view' = (random_books* :side_random + language_books * :side_lang "
"+ series_books * :side_series + category_books * :side_category + hot_books * "
":side_hot + :side_autor + :detail_random)"),
{'side_random': constants.SIDEBAR_RANDOM, 'side_lang': constants.SIDEBAR_LANGUAGE,
'side_series': constants.SIDEBAR_SERIES, 'side_category': constants.SIDEBAR_CATEGORY,
'side_hot': constants.SIDEBAR_HOT, 'side_autor': constants.SIDEBAR_AUTHOR,
'detail_random': constants.DETAIL_RANDOM})
trans.commit()
try:
_session.query(exists().where(User.denied_tags)).scalar()
except exc.OperationalError: # Database is not compatible, some columns are missing
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE user ADD column `denied_tags` String DEFAULT ''"))
conn.execute(text("ALTER TABLE user ADD column `allowed_tags` String DEFAULT ''"))
conn.execute(text("ALTER TABLE user ADD column `denied_column_value` String DEFAULT ''"))
conn.execute(text("ALTER TABLE user ADD column `allowed_column_value` String DEFAULT ''"))
trans.commit()
try:
_session.query(exists().where(User.view_settings)).scalar()
except exc.OperationalError:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE user ADD column `view_settings` VARCHAR(10) DEFAULT '{}'"))
trans.commit()
try:
_session.query(exists().where(User.kobo_only_shelves_sync)).scalar()
except exc.OperationalError:
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("ALTER TABLE user ADD column `kobo_only_shelves_sync` SMALLINT DEFAULT 0"))
trans.commit()
try:
# check if name is in User table instead of nickname
_session.query(exists().where(User.name)).scalar()
except exc.OperationalError:
# Create new table user_id and copy contents of table user into it
with engine.connect() as conn:
trans = conn.begin()
conn.execute(text("CREATE TABLE user_id (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,"
"name VARCHAR(64),"
"email VARCHAR(120),"
"role SMALLINT,"
"password VARCHAR,"
"kindle_mail VARCHAR(120),"
"locale VARCHAR(2),"
"sidebar_view INTEGER,"
"default_language VARCHAR(3),"
"denied_tags VARCHAR,"
"allowed_tags VARCHAR,"
"denied_column_value VARCHAR,"
"allowed_column_value VARCHAR,"
"view_settings JSON,"
"kobo_only_shelves_sync SMALLINT,"
"UNIQUE (name),"
"UNIQUE (email))"))
conn.execute(text("INSERT INTO user_id(id, name, email, role, password, kindle_mail,locale,"
"sidebar_view, default_language, denied_tags, allowed_tags, denied_column_value, "
"allowed_column_value, view_settings, kobo_only_shelves_sync)"
"SELECT id, nickname, email, role, password, kindle_mail, locale,"
"sidebar_view, default_language, denied_tags, allowed_tags, denied_column_value, "
"allowed_column_value, view_settings, kobo_only_shelves_sync FROM user"))
# delete old user table and rename new user_id table to user:
conn.execute(text("DROP TABLE user"))
conn.execute(text("ALTER TABLE user_id RENAME TO user"))
trans.commit()
if _session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS).first() \
is None:
create_anonymous_user(_session)
migrate_guest_password(engine)
migrate_user_session_table(engine, _session)
def clean_database(_session):
# Remove expired remote login tokens
now = datetime.datetime.now()
_session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).\
filter(RemoteAuthToken.token_type != 1).delete()
_session.commit()
try:
_session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).\
filter(RemoteAuthToken.token_type != 1).delete()
_session.commit()
except exc.OperationalError: # Database is not writeable
print('Settings database is not writeable. Exiting...')
sys.exit(2)
# Save downloaded books per user in calibre-web's own database

View File

@ -19,93 +19,123 @@
from functools import wraps
from sqlalchemy.sql.expression import func
from werkzeug.security import check_password_hash
from flask_login import login_required, login_user
from flask import request, Response
from .cw_login import login_required
from flask import request, g
from flask_httpauth import HTTPBasicAuth
from werkzeug.datastructures import Authorization
from werkzeug.security import check_password_hash
from . import lm, ub, config, logger, limiter, constants, services
from . import lm, ub, config, constants, services, logger, limiter
log = logger.create()
auth = HTTPBasicAuth()
@auth.verify_password
def verify_password(username, password):
user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first()
if user:
if user.name.lower() == "guest":
if config.config_anonbrowse == 1:
return user
if config.config_login_type == constants.LOGIN_LDAP and services.ldap:
login_result, error = services.ldap.bind_user(user.name, password)
if login_result:
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
return user
if error is not None:
log.error(error)
else:
limiter.check()
if check_password_hash(str(user.password), password):
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
return user
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
log.warning('OPDS Login failed for user "%s" IP-address: %s', username, ip_address)
return None
def requires_basic_auth_if_no_ano(f):
@wraps(f)
def decorated(*args, **kwargs):
authorisation = auth.get_auth()
status = None
user = None
if config.config_allow_reverse_proxy_header_login and not authorisation:
user = load_user_from_reverse_proxy_header(request)
if config.config_anonbrowse == 1 and not authorisation:
authorisation = Authorization(
b"Basic", {'username': "Guest", 'password': ""})
if not user:
user = auth.authenticate(authorisation, "")
if user in (False, None):
status = 401
if status:
try:
return auth.auth_error_callback(status)
except TypeError:
return auth.auth_error_callback()
g.flask_httpauth_user = user if user is not True \
else auth.username if auth else None
return auth.ensure_sync(f)(*args, **kwargs)
return decorated
def login_required_if_no_ano(func):
@wraps(func)
def decorated_view(*args, **kwargs):
if config.config_allow_reverse_proxy_header_login:
user = load_user_from_reverse_proxy_header(request)
if user:
g.flask_httpauth_user = user
return func(*args, **kwargs)
g.flask_httpauth_user = None
if config.config_anonbrowse == 1:
return func(*args, **kwargs)
return login_required(func)(*args, **kwargs)
return decorated_view
def requires_basic_auth_if_no_ano(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or auth.type != 'basic':
if config.config_anonbrowse != 1:
user = load_user_from_reverse_proxy_header(request)
if user:
return f(*args, **kwargs)
return _authenticate()
else:
return f(*args, **kwargs)
if config.config_login_type == constants.LOGIN_LDAP and services.ldap:
login_result, error = services.ldap.bind_user(auth.username, auth.password)
if login_result:
user = _fetch_user_by_name(auth.username)
def user_login_required(func):
@wraps(func)
def decorated_view(*args, **kwargs):
if config.config_allow_reverse_proxy_header_login:
user = load_user_from_reverse_proxy_header(request)
if user:
g.flask_httpauth_user = user
return func(*args, **kwargs)
g.flask_httpauth_user = None
return login_required(func)(*args, **kwargs)
return decorated_view
def load_user_from_reverse_proxy_header(req):
rp_header_name = config.config_reverse_proxy_login_header_name
if rp_header_name:
rp_header_username = req.headers.get(rp_header_name)
if rp_header_username:
user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == rp_header_username.lower()).first()
if user:
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
login_user(user)
return f(*args, **kwargs)
elif login_result is not None:
log.error(error)
return _authenticate()
user = _load_user_from_auth_header(auth.username, auth.password)
if not user:
return _authenticate()
return f(*args, **kwargs)
return decorated
def _load_user_from_auth_header(username, password):
limiter.check()
user = _fetch_user_by_name(username)
if bool(user and check_password_hash(str(user.password), password)) and user.name != "Guest":
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
login_user(user)
return user
else:
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
log.warning('OPDS Login failed for user "%s" IP-address: %s', username, ip_address)
return None
def _authenticate():
return Response(
'Could not verify your access level for that URL.\n'
'You have to login with proper credentials', 401,
{'WWW-Authenticate': 'Basic realm="Login Required"'})
def _fetch_user_by_name(username):
return ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first()
return user
return None
@lm.user_loader
def load_user(user_id):
def load_user(user_id, random, session_key):
user = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
if session_key:
entry = ub.session.query(ub.User_Sessions).filter(ub.User_Sessions.random == random,
ub.User_Sessions.session_key == session_key).first()
if not entry or entry.user_id != user.id:
return None
elif random:
entry = ub.session.query(ub.User_Sessions).filter(ub.User_Sessions.random == random).first()
if not entry or entry.user_id != user.id:
return None
return user
@lm.request_loader
def load_user_from_reverse_proxy_header(req):
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 = req.headers.get(rp_header_name)
if rp_header_username:
user = _fetch_user_by_name(rp_header_username)
if user:
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
login_user(user)
return user
return None

View File

@ -29,7 +29,7 @@ from flask import request, redirect, send_from_directory, make_response, flash,
from flask import session as flask_session
from flask_babel import gettext as _
from flask_babel import get_locale
from flask_login import login_user, logout_user, login_required, current_user
from .cw_login import login_user, logout_user, current_user
from flask_limiter import RateLimitExceeded
from flask_limiter.util import get_remote_address
from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
@ -59,6 +59,7 @@ from .kobo_sync_status import change_archived_books
from . import limiter
from .services.worker import WorkerThread
from .tasks_status import render_task_status
from .usermanagement import user_login_required
feature_support = {
@ -143,14 +144,14 @@ def viewer_required(f):
@web.route("/ajax/emailstat")
@login_required
@user_login_required
def get_email_status_json():
tasks = WorkerThread.get_instance().tasks
return jsonify(render_task_status(tasks))
@web.route("/ajax/bookmark/<int:book_id>/<book_format>", methods=['POST'])
@login_required
@user_login_required
def set_bookmark(book_id, book_format):
bookmark_key = request.form["bookmark"]
ub.session.query(ub.Bookmark).filter(and_(ub.Bookmark.user_id == int(current_user.id),
@ -170,7 +171,7 @@ def set_bookmark(book_id, book_format):
@web.route("/ajax/toggleread/<int:book_id>", methods=['POST'])
@login_required
@user_login_required
def toggle_read(book_id):
message = edit_book_read_status(book_id)
if message:
@ -180,7 +181,7 @@ def toggle_read(book_id):
@web.route("/ajax/togglearchived/<int:book_id>", methods=['POST'])
@login_required
@user_login_required
def toggle_archived(book_id):
is_archived = change_archived_books(book_id, message="Book {} archive bit toggled".format(book_id))
if is_archived:
@ -204,7 +205,7 @@ def update_view():
'''
@web.route("/ajax/getcomic/<int:book_id>/<book_format>/<int:page>")
@login_required
@user_login_required
def get_comic_book(book_id, book_format, page):
book = calibre_db.get_book(book_id)
if not book:
@ -816,7 +817,7 @@ def books_list(data, sort_param, book_id, page):
@web.route("/table")
@login_required
@user_login_required
def books_table():
visibility = current_user.view_settings.get('table', {})
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
@ -825,7 +826,7 @@ def books_table():
@web.route("/ajax/listbooks")
@login_required
@user_login_required
def list_books():
off = int(request.args.get("offset") or 0)
limit = int(request.args.get("limit") or config.config_books_per_page)
@ -906,7 +907,7 @@ def list_books():
@web.route("/ajax/table_settings", methods=['POST'])
@login_required
@user_login_required
def update_table_settings():
current_user.view_settings['table'] = json.loads(request.data)
try:
@ -1339,7 +1340,6 @@ def register():
def handle_login_user(user, remember, message, category):
login_user(user, remember=remember)
ub.store_user_session()
flash(message, category=category)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
return redirect(get_redirect_location(request.form.get('next', None), "web.index"))
@ -1443,7 +1443,7 @@ def login_post():
@web.route('/logout')
@login_required
@user_login_required
def logout():
if current_user is not None and current_user.is_authenticated:
ub.delete_user_session(current_user.id, flask_session.get('_id', ""))
@ -1528,7 +1528,7 @@ def change_profile(kobo_support, local_oauth_check, oauth_status, translations,
@web.route("/me", methods=["GET", "POST"])
@login_required
@user_login_required
def profile():
languages = calibre_db.speaking_language()
translations = get_available_locale()

View File

@ -2,7 +2,7 @@ Werkzeug<3.0.0
APScheduler>=3.6.3,<3.11.0
Babel>=1.3,<3.0
Flask-Babel>=0.11.1,<4.1.0
Flask-Login>=0.3.2,<0.6.4
# Flask-Login>=0.3.2,<0.6.4
Flask-Principal>=0.3.2,<0.5.1
Flask>=1.0.2,<3.1.0
iso-639>=0.4.5,<0.5.0
@ -21,3 +21,4 @@ Flask-Limiter>=2.3.0,<3.6.0
regex>=2022.3.2,<2024.6.25
bleach>=6.0.0,<6.2.0
python-magic>=0.4.27,<0.5.0
flask-httpAuth>=4.4.0