diff --git a/cps/__init__.py b/cps/__init__.py old mode 100644 new mode 100755 index f4f8dbf2..27b25e27 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -103,7 +103,7 @@ web_server = WebServer() updater_thread = Updater() if limiter_present: - limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=True) + limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=False) else: limiter = None @@ -196,8 +196,18 @@ def create_app(): config.config_use_goodreads) config.store_calibre_uuid(calibre_db, db.Library_Id) # Configure rate limiter + # https://limits.readthedocs.io/en/stable/storage.html app.config.update(RATELIMIT_ENABLED=config.config_ratelimiter) - limiter.init_app(app) + if config.config_limiter_uri != "" and not cli_param.memory_backend: + app.config.update(RATELIMIT_STORAGE_URI=config.config_limiter_uri) + if config.config_limiter_options != "": + app.config.update(RATELIMIT_STORAGE_OPTIONS=config.config_limiter_options) + try: + limiter.init_app(app) + except Exception as e: + log.error('Wrong Flask Limiter configuration, falling back to default: {}'.format(e)) + app.config.update(RATELIMIT_STORAGE_URI=None) + limiter.init_app(app) # Register scheduled tasks from .schedule import register_scheduled_tasks, register_startup_tasks diff --git a/cps/admin.py b/cps/admin.py old mode 100644 new mode 100755 index c07fc29f..d10c6290 --- a/cps/admin.py +++ b/cps/admin.py @@ -47,7 +47,8 @@ from . import constants, logger, helper, services, cli_param from . import db, calibre_db, ub, web_server, config, updater_thread, gdriveutils, \ kobo_sync_status, schedule from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ - valid_email, check_username, get_calibre_binarypath + valid_email, check_username +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 @@ -1716,7 +1717,7 @@ def _db_configuration_update_helper(): return _db_configuration_result('{}'.format(ex), gdrive_error) if db_change or not db_valid or not config.db_configured \ - or config.config_calibre_dir != to_save["config_calibre_dir"]: + or config.config_calibre_dir != to_save["config_calibre_dir"]: if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']: return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error) else: @@ -1840,6 +1841,8 @@ def _configuration_update_helper(): return _configuration_result(_('Password length has to be between 1 and 40')) reboot_required |= _config_int(to_save, "config_session") reboot_required |= _config_checkbox(to_save, "config_ratelimiter") + reboot_required |= _config_string(to_save, "config_limiter_uri") + reboot_required |= _config_string(to_save, "config_limiter_options") # Rarfile Content configuration _config_string(to_save, "config_rarfile_location") diff --git a/cps/cli.py b/cps/cli.py index 855ad899..64842259 100644 --- a/cps/cli.py +++ b/cps/cli.py @@ -52,6 +52,7 @@ class CliParameter(object): parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web', version=version_info()) parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen') + parser.add_argument('-m', action='store_true', help='Use Memory-backend as limiter backend, use this parameter in case of miss configured backend') parser.add_argument('-s', metavar='user:pass', help='Sets specific username to new password and exits Calibre-Web') parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version') @@ -98,6 +99,8 @@ class CliParameter(object): if args.k == "": self.keyfilepath = "" + # overwrite limiter backend + self.memory_backend = args.m or None # dry run updater self.dry_run = args.d or None # enable reconnect endpoint for docker database reconnect diff --git a/cps/config_sql.py b/cps/config_sql.py index dabf54f0..d95d5956 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -168,6 +168,8 @@ class _Settings(_Base): config_password_special = Column(Boolean, default=True) config_session = Column(Integer, default=1) config_ratelimiter = Column(Boolean, default=True) + config_limiter_uri = Column(String, default="") + config_limiter_options = Column(String, default="") def __repr__(self): return self.__class__.__name__ diff --git a/cps/editbooks.py b/cps/editbooks.py index 4d195eb7..d5fe580c 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -60,6 +60,7 @@ 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 editbook = Blueprint('edit-book', __name__) @@ -96,7 +97,7 @@ def delete_book_from_details(book_id): @editbook.route("/delete//", methods=["POST"]) @login_required def delete_book_ajax(book_id, book_format): - return delete_book_from_table(book_id, book_format, False) + return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', "")) @editbook.route("/admin/book/", methods=['GET']) @@ -823,7 +824,7 @@ def delete_whole_book(book_id, book): calibre_db.session.query(db.Books).filter(db.Books.id == book_id).delete() -def render_delete_book_result(book_format, json_response, warning, book_id): +def render_delete_book_result(book_format, json_response, warning, book_id, location=""): if book_format: if json_response: return json.dumps([warning, {"location": url_for("edit-book.show_edit_book", book_id=book_id), @@ -835,16 +836,16 @@ def render_delete_book_result(book_format, json_response, warning, book_id): return redirect(url_for('edit-book.show_edit_book', book_id=book_id)) else: if json_response: - return json.dumps([warning, {"location": url_for('web.index'), + return json.dumps([warning, {"location": get_redirect_location(location, "web.index"), "type": "success", "format": book_format, "message": _('Book Successfully Deleted')}]) else: flash(_('Book Successfully Deleted'), category="success") - return redirect(url_for('web.index')) + return redirect(get_redirect_location(location, "web.index")) -def delete_book_from_table(book_id, book_format, json_response): +def delete_book_from_table(book_id, book_format, json_response, location=""): warning = {} if current_user.role_delete_books(): book = calibre_db.get_book(book_id) @@ -891,7 +892,7 @@ def delete_book_from_table(book_id, book_format, json_response): else: # book not found log.error('Book with id "%s" could not be deleted: not found', book_id) - return render_delete_book_result(book_format, json_response, warning, book_id) + return render_delete_book_result(book_format, json_response, warning, book_id, location) message = _("You are missing permissions to delete books") if json_response: return json.dumps({"location": url_for("edit-book.show_edit_book", book_id=book_id), diff --git a/cps/embed_helper.py b/cps/embed_helper.py new file mode 100644 index 00000000..71de216d --- /dev/null +++ b/cps/embed_helper.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) +# Copyright (C) 2024 OzzieIsaacs +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +from uuid import uuid4 +import os + +from .file_helper import get_temp_dir +from .subproc_wrapper import process_open +from . import logger, config +from .constants import SUPPORTED_CALIBRE_BINARIES + +log = logger.create() + + +def do_calibre_export(book_id, book_format): + try: + quotes = [3, 5, 7, 9] + tmp_dir = get_temp_dir() + calibredb_binarypath = get_calibre_binarypath("calibredb") + temp_file_name = str(uuid4()) + my_env = os.environ.copy() + if config.config_calibre_split: + my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db") + library_path = config.config_calibre_split_dir + else: + library_path = config.config_calibre_dir + opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path, + '--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name), + str(book_id)] + p = process_open(opf_command, quotes, my_env) + _, err = p.communicate() + if err: + log.error('Metadata embedder encountered an error: %s', err) + return tmp_dir, temp_file_name + except OSError as ex: + # ToDo real error handling + log.error_or_exception(ex) + return None, None + + +def get_calibre_binarypath(binary): + binariesdir = config.config_binariesdir + if binariesdir: + try: + return os.path.join(binariesdir, SUPPORTED_CALIBRE_BINARIES[binary]) + except KeyError as ex: + log.error("Binary not supported by Calibre-Web: %s", SUPPORTED_CALIBRE_BINARIES[binary]) + pass + return "" diff --git a/cps/helper.py b/cps/helper.py index 4f2470f1..0cc7362c 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -28,7 +28,6 @@ from datetime import datetime, timedelta import requests import unidecode from uuid import uuid4 -from lxml import etree from flask import send_from_directory, make_response, redirect, abort, url_for from flask_babel import gettext as _ @@ -56,13 +55,14 @@ from .tasks.convert import TaskConvert from . import logger, config, db, ub, fs from . import gdriveutils as gd from .constants import STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES, SUPPORTED_CALIBRE_BINARIES -from .subproc_wrapper import process_wait, process_open +from .subproc_wrapper import process_wait from .services.worker import WorkerThread from .tasks.mail import TaskEmail from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails from .tasks.metadata_backup import TaskBackupMetadata from .file_helper import get_temp_dir from .epub_helper import get_content_opf, create_new_metadata_backup, updateEpub, replace_metadata +from .embed_helper import do_calibre_export log = logger.create() @@ -225,7 +225,7 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id) email_text = N_("%(book)s send to eReader", book=link) WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name, config.get_mail_settings(), ereader_mail, - email_text, _('This Email has been sent via Calibre-Web.'))) + email_text, _('This Email has been sent via Calibre-Web.'),book.id)) return return _("The requested file could not be read. Maybe wrong permissions?") @@ -692,15 +692,15 @@ def valid_password(check_password): if config.config_password_policy: verify = "" if config.config_password_min_length > 0: - verify += "^(?=.{" + str(config.config_password_min_length) + ",}$)" + verify += r"^(?=.{" + str(config.config_password_min_length) + ",}$)" if config.config_password_number: - verify += "(?=.*?\d)" + verify += r"(?=.*?\d)" if config.config_password_lower: - verify += "(?=.*?[a-z])" + verify += r"(?=.*?[a-z])" if config.config_password_upper: - verify += "(?=.*?[A-Z])" + verify += r"(?=.*?[A-Z])" if config.config_password_special: - verify += "(?=.*?[^A-Za-z\s0-9])" + verify += r"(?=.*?[^A-Za-z\s0-9])" match = re.match(verify, check_password) if not match: raise Exception(_("Password doesn't comply with password validation rules")) @@ -1001,33 +1001,6 @@ def do_kepubify_metadata_replace(book, file_path): return tmp_dir, temp_file_name -def do_calibre_export(book_id, book_format, ): - try: - quotes = [3, 5, 7, 9] - tmp_dir = get_temp_dir() - calibredb_binarypath = get_calibre_binarypath("calibredb") - temp_file_name = str(uuid4()) - my_env = os.environ.copy() - if config.config_calibre_split: - my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db") - library_path = config.config_calibre_split_dir - else: - library_path = config.config_calibre_dir - opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path, - '--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name), - str(book_id)] - # CALIBRE_OVERRIDE_DATABASE_PATH - p = process_open(opf_command, quotes, my_env) - _, err = p.communicate() - if err: - log.error('Metadata embedder encountered an error: %s', err) - return tmp_dir, temp_file_name - except OSError as ex: - # ToDo real error handling - log.error_or_exception(ex) - return None, None - - ################################## @@ -1066,7 +1039,7 @@ def check_calibre(calibre_location): binaries_available = [os.path.isfile(binary_path) for binary_path in supported_binary_paths] binaries_executable = [os.access(binary_path, os.X_OK) for binary_path in supported_binary_paths] if all(binaries_available) and all(binaries_executable): - values = [process_wait([binary_path, "--version"], pattern='\(calibre (.*)\)') + values = [process_wait([binary_path, "--version"], pattern=r'\(calibre (.*)\)') for binary_path in supported_binary_paths] if all(values): version = values[0].group(1) @@ -1149,17 +1122,6 @@ def get_download_link(book_id, book_format, client): abort(404) -def get_calibre_binarypath(binary): - binariesdir = config.config_binariesdir - if binariesdir: - try: - return os.path.join(binariesdir, SUPPORTED_CALIBRE_BINARIES[binary]) - except KeyError as ex: - log.error("Binary not supported by Calibre-Web: %s", SUPPORTED_CALIBRE_BINARIES[binary]) - pass - return "" - - def clear_cover_thumbnail_cache(book_id): if config.schedule_generate_book_covers: WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True) diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py index 3736e4e1..49b7e475 100644 --- a/cps/kobo_auth.py +++ b/cps/kobo_auth.py @@ -156,6 +156,9 @@ def requires_kobo_auth(f): 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) diff --git a/cps/redirect.py b/cps/redirect.py index 337bb77b..7f504b98 100755 --- a/cps/redirect.py +++ b/cps/redirect.py @@ -44,9 +44,9 @@ def remove_prefix(text, prefix): return "" -def redirect_back(endpoint, **values): - target = request.form.get('next', None) or url_for(endpoint, **values) +def get_redirect_location(next, endpoint, **values): + target = next or url_for(endpoint, **values) adapter = current_app.url_map.bind(urlparse(request.host_url).netloc) if not len(adapter.allowed_methods(remove_prefix(target, request.environ.get('HTTP_X_SCRIPT_NAME',"")))): target = url_for(endpoint, **values) - return redirect(target) + return target diff --git a/cps/services/worker.py b/cps/services/worker.py index 63d83bfb..dce3da79 100644 --- a/cps/services/worker.py +++ b/cps/services/worker.py @@ -266,3 +266,6 @@ class CalibreTask: def _handleSuccess(self): self.stat = STAT_FINISH_SUCCESS self.progress = 1 + + def __str__(self): + return self.name diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 1e88fc6d..6b183b07 100644 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -20,7 +20,7 @@ function getPath() { return jsFileLocation.substr(0, jsFileLocation.search("/static/js/libs/jquery.min.js")); // the js folder path } -function postButton(event, action){ +function postButton(event, action, location=""){ event.preventDefault(); var newForm = jQuery('
', { "action": action, @@ -30,7 +30,14 @@ function postButton(event, action){ 'name': 'csrf_token', 'value': $("input[name=\'csrf_token\']").val(), 'type': 'hidden' - })).appendTo('body'); + })).appendTo('body') + if(location !== "") { + newForm.append(jQuery('', { + 'name': 'location', + 'value': location, + 'type': 'hidden' + })).appendTo('body'); + } newForm.submit(); } @@ -212,17 +219,20 @@ $("#delete_confirm").click(function(event) { $( ".navbar" ).after( '
' + '
'+item.message+'
' + '
'); - } }); $("#books-table").bootstrapTable("refresh"); } }); } else { - postButton(event, getPath() + "/delete/" + deleteId); + var loc = sessionStorage.getItem("back"); + if (!loc) { + loc = $(this).data("back"); + } + sessionStorage.removeItem("back"); + postButton(event, getPath() + "/delete/" + deleteId, location=loc); } } - }); //triggered when modal is about to be shown @@ -541,6 +551,7 @@ $(function() { $.get(e.relatedTarget.href).done(function(content) { $modalBody.html(content); preFilters.remove(useCache); + $("#back").remove(); }); }) .on("hidden.bs.modal", function() { diff --git a/cps/tasks/convert.py b/cps/tasks/convert.py index 21f99668..3a121a2e 100644 --- a/cps/tasks/convert.py +++ b/cps/tasks/convert.py @@ -110,6 +110,7 @@ class TaskConvert(CalibreTask): self.ereader_mail, EmailText, self.settings['body'], + id=self.book_id, internal=True) ) except Exception as ex: @@ -315,9 +316,9 @@ class TaskConvert(CalibreTask): def __str__(self): if self.ereader_mail: - return "Convert {} {}".format(self.book_id, self.ereader_mail) + return "Convert Book {} and mail it to {}".format(self.book_id, self.ereader_mail) else: - return "Convert {}".format(self.book_id) + return "Convert Book {}".format(self.book_id) @property def is_cancellable(self): diff --git a/cps/tasks/mail.py b/cps/tasks/mail.py index 36133ccf..39ad919f 100644 --- a/cps/tasks/mail.py +++ b/cps/tasks/mail.py @@ -28,12 +28,11 @@ from email.message import EmailMessage from email.utils import formatdate, parseaddr from email.generator import Generator from flask_babel import lazy_gettext as N_ -from email.utils import formatdate from cps.services.worker import CalibreTask from cps.services import gmail +from cps.embed_helper import do_calibre_export from cps import logger, config - from cps import gdriveutils import uuid @@ -110,7 +109,7 @@ class EmailSSL(EmailBase, smtplib.SMTP_SSL): class TaskEmail(CalibreTask): - def __init__(self, subject, filepath, attachment, settings, recipient, task_message, text, internal=False): + def __init__(self, subject, filepath, attachment, settings, recipient, task_message, text, id=0, internal=False): super(TaskEmail, self).__init__(task_message) self.subject = subject self.attachment = attachment @@ -119,6 +118,7 @@ class TaskEmail(CalibreTask): self.recipient = recipient self.text = text self.asyncSMTP = None + self.book_id = id self.results = dict() # from calibre code: @@ -141,7 +141,7 @@ class TaskEmail(CalibreTask): message['To'] = self.recipient message['Subject'] = self.subject message['Date'] = formatdate(localtime=True) - message['Message-Id'] = "{}@{}".format(uuid.uuid4(), self.get_msgid_domain()) # f"<{uuid.uuid4()}@{get_msgid_domain(from_)}>" # make_msgid('calibre-web') + message['Message-Id'] = "{}@{}".format(uuid.uuid4(), self.get_msgid_domain()) message.set_content(self.text.encode('UTF-8'), "text", "plain") if self.attachment: data = self._get_attachment(self.filepath, self.attachment) @@ -161,6 +161,8 @@ class TaskEmail(CalibreTask): try: # create MIME message msg = self.prepare_message() + if not msg: + return if self.settings['mail_server_type'] == 0: self.send_standard_email(msg) else: @@ -236,10 +238,10 @@ class TaskEmail(CalibreTask): self.asyncSMTP = None self._progress = x - @classmethod - def _get_attachment(cls, book_path, filename): + def _get_attachment(self, book_path, filename): """Get file as MIMEBase message""" calibre_path = config.get_book_path() + extension = os.path.splitext(filename)[1][1:] if config.config_use_google_drive: df = gdriveutils.getFileFromEbooksFolder(book_path, filename) if df: @@ -249,15 +251,22 @@ class TaskEmail(CalibreTask): df.GetContentFile(datafile) else: return None - file_ = open(datafile, 'rb') - data = file_.read() - file_.close() + if config.config_binariesdir and config.config_embed_metadata: + data_path, data_file = do_calibre_export(self.book_id, extension) + datafile = os.path.join(data_path, data_file + "." + extension) + with open(datafile, 'rb') as file_: + data = file_.read() os.remove(datafile) else: + datafile = os.path.join(calibre_path, book_path, filename) try: - file_ = open(os.path.join(calibre_path, book_path, filename), 'rb') - data = file_.read() - file_.close() + if config.config_binariesdir and config.config_embed_metadata: + data_path, data_file = do_calibre_export(self.book_id, extension) + datafile = os.path.join(data_path, data_file + "." + extension) + with open(datafile, 'rb') as file_: + data = file_.read() + if config.config_binariesdir and config.config_embed_metadata: + os.remove(datafile) except IOError as e: log.error_or_exception(e, stacklevel=3) log.error('The requested file could not be read. Maybe wrong permissions?') diff --git a/cps/templates/author.html b/cps/templates/author.html index b991e959..3e82161c 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -32,7 +32,7 @@
{% for entry in entries %} -
+
@@ -99,7 +99,7 @@

{{_("More by")}} {{ author.name.replace('|',',') }}

{% for entry in other_books %} -
+
- +
diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html old mode 100644 new mode 100755 index d83831db..695cd823 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -105,7 +105,7 @@
- +
@@ -372,6 +372,16 @@
+
+
+ + +
+
+ + +
+