diff --git a/cps/kobo.py b/cps/kobo.py index da9c9bc55..5f9db85a6 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -19,38 +19,30 @@ import base64 from datetime import datetime, timezone +from functools import wraps import os import uuid import zipfile from time import gmtime, strftime import json from urllib.parse import unquote +import requests -from flask import ( - Blueprint, - request, - make_response, - jsonify, - current_app, - url_for, - redirect, - abort -) -from .cw_login import current_user +from flask import Blueprint, request, make_response, jsonify, current_app, url_for, redirect, abort, g from werkzeug.datastructures import Headers from sqlalchemy import func from sqlalchemy.sql.expression import and_, or_ from sqlalchemy.exc import StatementError -import requests +from flask_limiter.util import get_remote_address +from .cw_login import current_user, login_user 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 from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE, BASE_DIR from .helper import get_download_link from .services import SyncToken as SyncToken from .web import download_required -from .kobo_auth import requires_kobo_auth, get_auth_token +from . import isoLanguages, limiter KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]} KOBO_STOREAPI_URL = "https://storeapi.kobo.com" @@ -65,6 +57,33 @@ kobo_auth.register_url_value_preprocessor(kobo) log = logger.create() +def get_auth_token(): + if "auth_token" in g: + return g.get("auth_token") + else: + return None + + +def requires_kobo_auth(f): + @wraps(f) + def inner(*args, **kwargs): + auth_token = get_auth_token() + if auth_token is not None: + user = ( + ub.session.query(ub.User) + .join(ub.RemoteAuthToken) + .filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1) + .first() + ) + if user is not None: + login_user(user) + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits] + return f(*args, **kwargs) + log.debug("Received Kobo request without a recognizable auth token.") + return abort(401) + return inner + + def get_store_url_for_current_request(): # Programmatically modify the current url to point to the official Kobo store __, __, request_path_with_auth_token = request.full_path.rpartition("/kobo/") @@ -140,7 +159,7 @@ def convert_to_kobo_timestamp_string(timestamp): @kobo.route("/v1/library/sync") @requires_kobo_auth -# @download_required +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleSyncRequest(): if not current_user.role_download(): log.info("Users need download permissions for syncing library to Kobo reader") @@ -336,6 +355,7 @@ def generate_sync_response(sync_token, sync_results, set_cont=False): @kobo.route("/v1/library//metadata") @requires_kobo_auth @download_required +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleMetadataRequest(book_uuid): if not current_app.wsgi_app.is_proxied: log.debug('Kobo: Received unproxied request, changed request port to external server port') @@ -368,7 +388,7 @@ def get_download_url_for_book(book_id, book_format): ) return url_for( "kobo.download_book", - auth_token=kobo_auth.get_auth_token(), + auth_token=get_auth_token(), book_id=book_id, book_format=book_format.lower(), _external=True, @@ -503,9 +523,11 @@ def get_metadata(book): return metadata + @csrf.exempt @kobo.route("/v1/library/tags", methods=["POST", "DELETE"]) @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) # Creates a Shelf with the given items, and returns the shelf's uuid. def HandleTagCreate(): # catch delete requests, otherwise they are handled in the book delete handler @@ -541,6 +563,7 @@ def HandleTagCreate(): @csrf.exempt @kobo.route("/v1/library/tags/", methods=["DELETE", "PUT"]) @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleTagUpdate(tag_id): shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.uuid == tag_id, ub.Shelf.user_id == current_user.id).one_or_none() @@ -595,6 +618,7 @@ def add_items_to_shelf(items, shelf): @csrf.exempt @kobo.route("/v1/library/tags//items", methods=["POST"]) @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleTagAddItem(tag_id): items = None try: @@ -625,6 +649,7 @@ def HandleTagAddItem(tag_id): @csrf.exempt @kobo.route("/v1/library/tags//items/delete", methods=["POST"]) @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleTagRemoveItem(tag_id): items = None try: @@ -758,6 +783,7 @@ def create_kobo_tag(shelf): @csrf.exempt @kobo.route("/v1/library//state", methods=["GET", "PUT"]) @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleStateRequest(book_uuid): book = calibre_db.get_book_by_uuid(book_uuid) if not book or not book.data: @@ -907,6 +933,7 @@ def get_current_bookmark_response(current_bookmark): @kobo.route("/////image.jpg", defaults={'Quality': ""}) @kobo.route("//////image.jpg") @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale): try: if int(height) > 1000: @@ -943,6 +970,7 @@ def TopLevelEndpoint(): @csrf.exempt @kobo.route("/v1/library/", methods=["DELETE"]) @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleBookDeletionRequest(book_uuid): log.info("Kobo book delete request received for book %s" % book_uuid) book = calibre_db.get_book_by_uuid(book_uuid) @@ -962,6 +990,7 @@ def HandleBookDeletionRequest(book_uuid): @kobo.route("/v1/library/", methods=["DELETE", "GET", "POST"]) @kobo.route("/v1/library//preview", methods=["POST"]) def HandleUnimplementedRequest(dummy=None): + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits] log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)", request.base_url) return redirect_or_proxy_request() @@ -976,6 +1005,9 @@ def HandleUnimplementedRequest(dummy=None): @kobo.route("/v1/analytics/", methods=["GET", "POST"]) @kobo.route("/v1/assets", methods=["GET"]) def HandleUserRequest(dummy=None): + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits] + log.error("Key: {}".format(limiter.current_limit.key)) + log.error("Remaining: {}".format(limiter.current_limit.remaining)) log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url) return redirect_or_proxy_request() @@ -983,6 +1015,7 @@ def HandleUserRequest(dummy=None): @csrf.exempt @kobo.route("/v1/user/loyalty/benefits", methods=["GET"]) def handle_benefits(): + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits] if config.config_kobo_proxy: return redirect_or_proxy_request() else: @@ -992,6 +1025,7 @@ def handle_benefits(): @csrf.exempt @kobo.route("/v1/analytics/gettests", methods=["GET", "POST"]) def handle_getests(): + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits] if config.config_kobo_proxy: return redirect_or_proxy_request() else: @@ -1018,6 +1052,7 @@ def handle_getests(): @kobo.route("/v1/affiliate", methods=["GET", "POST"]) @kobo.route("/v1/deals", methods=["GET", "POST"]) def HandleProductsRequest(dummy=None): + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits] log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)", request.base_url) return redirect_or_proxy_request() @@ -1046,7 +1081,10 @@ def make_calibre_web_auth_response(): @kobo.route("/v1/auth/refresh", methods=["POST"]) @kobo.route("/v1/auth/device", methods=["POST"]) @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleAuthRequest(): + log.error(limiter.current_limit) + log.error(limiter.current_limit) log.debug('Kobo Auth request') if config.config_kobo_proxy: try: @@ -1058,6 +1096,7 @@ def HandleAuthRequest(): @kobo.route("/v1/initialization") @requires_kobo_auth +# @limiter.limit("3/minute", key_func=get_remote_address) def HandleInitRequest(): log.info('Init') @@ -1088,7 +1127,7 @@ def HandleInitRequest(): kobo_resources["image_host"] = calibre_web_url kobo_resources["image_url_quality_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest", - auth_token=kobo_auth.get_auth_token(), + auth_token=get_auth_token(), book_uuid="{ImageId}", width="{width}", height="{height}", @@ -1096,7 +1135,7 @@ def HandleInitRequest(): isGreyscale='isGreyscale')) kobo_resources["image_url_template"] = unquote(calibre_web_url + url_for("kobo.HandleCoverImageRequest", - auth_token=kobo_auth.get_auth_token(), + auth_token=get_auth_token(), book_uuid="{ImageId}", width="{width}", height="{height}", @@ -1104,7 +1143,7 @@ def HandleInitRequest(): else: kobo_resources["image_host"] = url_for("web.index", _external=True).strip("/") kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest", - auth_token=kobo_auth.get_auth_token(), + auth_token=get_auth_token(), book_uuid="{ImageId}", width="{width}", height="{height}", @@ -1112,7 +1151,7 @@ def HandleInitRequest(): isGreyscale='isGreyscale', _external=True)) kobo_resources["image_url_template"] = unquote(url_for("kobo.HandleCoverImageRequest", - auth_token=kobo_auth.get_auth_token(), + auth_token=get_auth_token(), book_uuid="{ImageId}", width="{width}", height="{height}", @@ -1128,6 +1167,7 @@ def HandleInitRequest(): @kobo.route("/download//") @requires_kobo_auth @download_required +# @limiter.limit("3/minute", key_func=get_remote_address) def download_book(book_id, book_format): return get_download_link(book_id, book_format, "kobo") @@ -1299,3 +1339,5 @@ def NATIVE_KOBO_RESOURCES(): "userguide_host": "https://ereaderfiles.kobo.com", "wishlist_page": "https://www.kobo.com/{region}/{language}/account/wishlist" } + +limiter.limit("3/minute", key_func=get_remote_address)(kobo) diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py index 9a5b47fc1..042fb1c24 100644 --- a/cps/kobo_auth.py +++ b/cps/kobo_auth.py @@ -62,14 +62,12 @@ particular calls to non-Kobo specific endpoints such as the CalibreWeb book down from binascii import hexlify from datetime import datetime from os import urandom -from functools import wraps -from flask import g, Blueprint, abort, request -from .cw_login import login_user, current_user +from flask import g, Blueprint, request +from .cw_login import 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 . import logger, config, calibre_db, db, helper, ub, lm from .render_template import render_title_template from .usermanagement import user_login_required @@ -135,13 +133,6 @@ def disable_failed_auth_redirect_for_blueprint(bp): lm.blueprint_login_views[bp.name] = None -def get_auth_token(): - if "auth_token" in g: - return g.get("auth_token") - else: - return None - - def register_url_value_preprocessor(kobo): @kobo.url_value_preprocessor # pylint: disable=unused-variable @@ -149,28 +140,3 @@ def register_url_value_preprocessor(kobo): g.auth_token = values.pop("auth_token") -def requires_kobo_auth(f): - @wraps(f) - def inner(*args, **kwargs): - auth_token = get_auth_token() - if auth_token is not None: - #try: - # limiter.check() - #except RateLimitExceeded: - # return abort(429) - #except (ConnectionError, Exception) as e: - # log.error("Connection error to limiter backend: %s", e) - # return abort(429) - user = ( - ub.session.query(ub.User) - .join(ub.RemoteAuthToken) - .filter(ub.RemoteAuthToken.auth_token == auth_token).filter(ub.RemoteAuthToken.token_type==1) - .first() - ) - if user is not None: - login_user(user) - [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits] - return f(*args, **kwargs) - log.debug("Received Kobo request without a recognizable auth token.") - return abort(401) - return inner diff --git a/cps/main.py b/cps/main.py index b0f56b6d4..4b0e5440e 100644 --- a/cps/main.py +++ b/cps/main.py @@ -46,11 +46,10 @@ def main(): try: from .kobo import kobo, get_kobo_activated from .kobo_auth import kobo_auth - from flask_limiter.util import get_remote_address kobo_available = get_kobo_activated() except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator) kobo_available = False - kobo = kobo_auth = get_remote_address = None + kobo = kobo_auth = None try: from .oauth_bb import oauth @@ -77,7 +76,6 @@ def main(): app.register_blueprint(gdrive) app.register_blueprint(editbook) if kobo_available: - limiter.limit("3/minute", key_func=get_remote_address)(kobo) app.register_blueprint(kobo) app.register_blueprint(kobo_auth) if oauth_available: diff --git a/cps/usermanagement.py b/cps/usermanagement.py index c7fdf9492..e9f0776be 100644 --- a/cps/usermanagement.py +++ b/cps/usermanagement.py @@ -43,14 +43,14 @@ def verify_password(username, password): 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] + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit 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] + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit 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) @@ -120,7 +120,7 @@ def load_user_from_reverse_proxy_header(req): 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] + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits] return user return None diff --git a/cps/web.py b/cps/web.py index 5ee7621f2..961e1df7f 100644 --- a/cps/web.py +++ b/cps/web.py @@ -30,7 +30,6 @@ from flask import session as flask_session from flask_babel import gettext as _ from flask_babel import get_locale 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 from sqlalchemy.sql.expression import text, func, false, not_, and_, or_ @@ -39,7 +38,7 @@ from sqlalchemy.sql.functions import coalesce from werkzeug.datastructures import Headers from werkzeug.security import generate_password_hash, check_password_hash -from . import constants, logger, isoLanguages, services +from . import constants, logger, isoLanguages, services, limiter from . import db, ub, config, app from . import calibre_db, kobo_sync_status from .search import render_search_results, render_adv_search_results @@ -55,7 +54,6 @@ from .usermanagement import login_required_if_no_ano from .kobo_sync_status import remove_synced_book from .render_template import render_title_template 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 @@ -1285,15 +1283,6 @@ def register_post(): if not config.config_public_reg: abort(404) to_save = request.form.to_dict() - #try: - # limiter.check() - #except RateLimitExceeded: - # flash(_(u"Please wait one minute to register next user"), category="error") - # return render_title_template('register.html', config=config, title=_("Register"), page="register") - #except (ConnectionError, Exception) as e: - # log.error("Connection error to limiter backend: %s", e) - # flash(_("Connection error to limiter backend, please contact your administrator"), category="error") - # return render_title_template('register.html', config=config, title=_("Register"), page="register") if current_user is not None and current_user.is_authenticated: return redirect(url_for('web.index')) if not config.get_mail_server_configured(): @@ -1358,7 +1347,7 @@ def register(): def handle_login_user(user, remember, message, category): login_user(user, remember=remember) flash(message, category=category) - [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits] + [limiter.limiter.clear(limit.limit, *limit.request_args) for limit in limiter.current_limits] return redirect(get_redirect_location(request.form.get('next', None), "web.index")) @@ -1392,15 +1381,6 @@ def login(): def login_post(): form = request.form.to_dict() username = strip_whitespaces(form.get('username', "")).lower().replace("\n","").replace("\r","") - #try: - # limiter.check() - #except RateLimitExceeded: - # flash(_("Please wait one minute before next login"), category="error") - # return render_login(username, form.get("password", "")) - #except (ConnectionError, Exception) as e: - # log.error("Connection error to limiter backend: %s", e) - # flash(_("Connection error to limiter backend, please contact your administrator"), category="error") - # return render_login(username, form.get("password", "")) if current_user is not None and current_user.is_authenticated: return redirect(url_for('web.index')) if config.config_login_type == constants.LOGIN_LDAP and not services.ldap: diff --git a/pyproject.toml b/pyproject.toml index b5d54ad4e..42b912285 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,26 +33,26 @@ dependencies = [ "Flask>=1.0.2,<3.2.0", "iso-639>=0.4.5,<0.5.0;python_version<'3.12'", "pycountry>=20.0.0,<25.0.0;python_version>='3.12'", - "PyPDF>=6.1.3,<6.5.0", + "PyPDF>=6.1.3,<6.7.0", "pytz>=2016.10", "requests>=2.32.0,<2.33.0", "SQLAlchemy>=1.3.0,<2.1.0", "tornado>=6.4.2,<6.6", "Wand>=0.4.4,<0.7.0", - "unidecode>=0.04.19,<1.4.0", + "unidecode>=0.04.19,<1.5.0", "lxml>=4.9.1,<5.4.0", "flask-wtf>=0.14.2,<1.3.0", "chardet>=3.0.0,<5.3.0", "netifaces-plus>=0.12.0,<0.13.0", "urllib3>=1.22,<3.0", - "Flask-Limiter>=2.3.0,<3.13.0", - "regex>=2022.3.2,<2025.3.20", - "bleach>=6.0.0,<6.3.0", + "Flask-Limiter>=2.3.0,<4.2.0", + "regex>=2022.3.2,<2026.1.16", + "bleach>=6.0.0,<6.4.0", "python-magic>=0.4.27,<0.5.0", "python-magic-bin>=0.4.0,<0.5.0;sys_platform=='win32'", "flask-httpAuth>=4.4.0,<5.0.0", - "cryptography>=39.0.0,<45.0.0", - "certifi>=2024.7.4,<2025.8.24", + "cryptography>=39.0.0,<47.0.0", + "certifi>=2024.7.4,<2026.1.5", ] dynamic = ["version"] @@ -70,9 +70,9 @@ content-type = "text/markdown" [project.optional-dependencies] gdrive = [ "google-api-python-client>=2.73.00,<2.200.0", - "gevent>20.6.0,<24.12.0", - "greenlet>=0.4.17,<3.3.0", - "httplib2>=0.9.2,<0.23.0", + "gevent>20.6.0,<25.9.2", + "greenlet>=0.4.17,<3.4.0", + "httplib2>=0.9.2,<0.32.0", "oauth2client>=4.0.0,<4.1.4", "uritemplate>=3.0.0,<4.3.0", "pyasn1-modules>=0.0.8,<0.7.0", @@ -91,19 +91,19 @@ goodreads = [ ] ldap = [ "python-ldap>=3.0.0,<3.5.0", - "Flask-SimpleLDAP>=1.4.0,<2.1.0", + "Flask-SimpleLDAP>=1.4.0,<2.2.0", ] oauth = [ "Flask-Dance>=2.0.0,<7.2.0", - "SQLAlchemy-Utils>=0.33.5,<0.42.0", + "SQLAlchemy-Utils>=0.33.5,<0.43.0", ] metadata = [ "rarfile>=3.2,<5.0", "scholarly>=1.2.0,<1.8", "markdown2>=2.0.0,<2.6.0", - "html2text>=2020.1.16,<2025.2.26", + "html2text>=2020.1.16,<2025.4.16", "python-dateutil>=2.1,<2.10.0", - "beautifulsoup4>=4.0.1,<4.14.0", + "beautifulsoup4>=4.0.1,<4.15.0", "faust-cchardet>=2.1.18,<2.1.20", "py7zr>=0.15.0,<0.21.0", "mutagen>=1.40.0,<1.50.0", diff --git a/test/Calibre-Web TestSummary_Linux.html b/test/Calibre-Web TestSummary_Linux.html index 962374c38..029fa4d88 100644 --- a/test/Calibre-Web TestSummary_Linux.html +++ b/test/Calibre-Web TestSummary_Linux.html @@ -37,20 +37,20 @@
-

Start Time: 2026-01-24 20:50:12

+

Start Time: 2026-02-17 19:21:22

-

Stop Time: 2026-01-25 04:15:44

+

Stop Time: 2026-02-18 02:50:40

-

Duration: 6h 13 min

+

Duration: 6h 14 min

@@ -2039,13 +2039,13 @@ - + TestLoadMetadata 1 0 - 0 1 0 + 0 Detail @@ -2053,27 +2053,26 @@ - +
TestLoadMetadata - test_load_metadata
- ERROR + FAIL
-