Merge branch 'Develop'

- Back function for delete and edit books
- configure ratelimiter backend possible
- embed metadata during send to ereader
- bugfixes split library
- updated requirements
This commit is contained in:
Ozzie Isaacs 2024-02-27 06:03:54 +01:00
commit c30460d76b
25 changed files with 542 additions and 330 deletions

14
cps/__init__.py Normal file → Executable file
View File

@ -103,7 +103,7 @@ web_server = WebServer()
updater_thread = Updater() updater_thread = Updater()
if limiter_present: 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: else:
limiter = None limiter = None
@ -196,8 +196,18 @@ def create_app():
config.config_use_goodreads) config.config_use_goodreads)
config.store_calibre_uuid(calibre_db, db.Library_Id) config.store_calibre_uuid(calibre_db, db.Library_Id)
# Configure rate limiter # Configure rate limiter
# https://limits.readthedocs.io/en/stable/storage.html
app.config.update(RATELIMIT_ENABLED=config.config_ratelimiter) 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 # Register scheduled tasks
from .schedule import register_scheduled_tasks, register_startup_tasks from .schedule import register_scheduled_tasks, register_startup_tasks

7
cps/admin.py Normal file → Executable file
View File

@ -47,7 +47,8 @@ from . import constants, logger, helper, services, cli_param
from . import db, calibre_db, ub, web_server, config, updater_thread, gdriveutils, \ from . import db, calibre_db, ub, web_server, config, updater_thread, gdriveutils, \
kobo_sync_status, schedule kobo_sync_status, schedule
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ 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 .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config from .render_template import render_title_template, get_sidebar_config
from .services.worker import WorkerThread from .services.worker import WorkerThread
@ -1716,7 +1717,7 @@ def _db_configuration_update_helper():
return _db_configuration_result('{}'.format(ex), gdrive_error) return _db_configuration_result('{}'.format(ex), gdrive_error)
if db_change or not db_valid or not config.db_configured \ 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']: 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) return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
else: else:
@ -1840,6 +1841,8 @@ def _configuration_update_helper():
return _configuration_result(_('Password length has to be between 1 and 40')) return _configuration_result(_('Password length has to be between 1 and 40'))
reboot_required |= _config_int(to_save, "config_session") reboot_required |= _config_int(to_save, "config_session")
reboot_required |= _config_checkbox(to_save, "config_ratelimiter") 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 # Rarfile Content configuration
_config_string(to_save, "config_rarfile_location") _config_string(to_save, "config_rarfile_location")

View File

@ -52,6 +52,7 @@ class CliParameter(object):
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web', parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
version=version_info()) version=version_info())
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen') 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', parser.add_argument('-s', metavar='user:pass',
help='Sets specific username to new password and exits Calibre-Web') 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') 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 == "": if args.k == "":
self.keyfilepath = "" self.keyfilepath = ""
# overwrite limiter backend
self.memory_backend = args.m or None
# dry run updater # dry run updater
self.dry_run = args.d or None self.dry_run = args.d or None
# enable reconnect endpoint for docker database reconnect # enable reconnect endpoint for docker database reconnect

View File

@ -168,6 +168,8 @@ class _Settings(_Base):
config_password_special = Column(Boolean, default=True) config_password_special = Column(Boolean, default=True)
config_session = Column(Integer, default=1) config_session = Column(Integer, default=1)
config_ratelimiter = Column(Boolean, default=True) config_ratelimiter = Column(Boolean, default=True)
config_limiter_uri = Column(String, default="")
config_limiter_options = Column(String, default="")
def __repr__(self): def __repr__(self):
return self.__class__.__name__ return self.__class__.__name__

View File

@ -60,6 +60,7 @@ from .tasks.upload import TaskUpload
from .render_template import render_title_template from .render_template import render_title_template
from .usermanagement import login_required_if_no_ano from .usermanagement import login_required_if_no_ano
from .kobo_sync_status import change_archived_books from .kobo_sync_status import change_archived_books
from .redirect import get_redirect_location
editbook = Blueprint('edit-book', __name__) editbook = Blueprint('edit-book', __name__)
@ -96,7 +97,7 @@ def delete_book_from_details(book_id):
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"]) @editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
@login_required @login_required
def delete_book_ajax(book_id, book_format): 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/<int:book_id>", methods=['GET']) @editbook.route("/admin/book/<int:book_id>", 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() 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 book_format:
if json_response: if json_response:
return json.dumps([warning, {"location": url_for("edit-book.show_edit_book", book_id=book_id), 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)) return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
else: else:
if json_response: 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", "type": "success",
"format": book_format, "format": book_format,
"message": _('Book Successfully Deleted')}]) "message": _('Book Successfully Deleted')}])
else: else:
flash(_('Book Successfully Deleted'), category="success") 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 = {} warning = {}
if current_user.role_delete_books(): if current_user.role_delete_books():
book = calibre_db.get_book(book_id) book = calibre_db.get_book(book_id)
@ -891,7 +892,7 @@ def delete_book_from_table(book_id, book_format, json_response):
else: else:
# book not found # book not found
log.error('Book with id "%s" could not be deleted: not found', book_id) 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") message = _("You are missing permissions to delete books")
if json_response: if json_response:
return json.dumps({"location": url_for("edit-book.show_edit_book", book_id=book_id), return json.dumps({"location": url_for("edit-book.show_edit_book", book_id=book_id),

63
cps/embed_helper.py Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
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 ""

View File

@ -28,7 +28,6 @@ from datetime import datetime, timedelta
import requests import requests
import unidecode import unidecode
from uuid import uuid4 from uuid import uuid4
from lxml import etree
from flask import send_from_directory, make_response, redirect, abort, url_for from flask import send_from_directory, make_response, redirect, abort, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
@ -56,13 +55,14 @@ from .tasks.convert import TaskConvert
from . import logger, config, db, ub, fs from . import logger, config, db, ub, fs
from . import gdriveutils as gd 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 .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 .services.worker import WorkerThread
from .tasks.mail import TaskEmail from .tasks.mail import TaskEmail
from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails
from .tasks.metadata_backup import TaskBackupMetadata from .tasks.metadata_backup import TaskBackupMetadata
from .file_helper import get_temp_dir from .file_helper import get_temp_dir
from .epub_helper import get_content_opf, create_new_metadata_backup, updateEpub, replace_metadata from .epub_helper import get_content_opf, create_new_metadata_backup, updateEpub, replace_metadata
from .embed_helper import do_calibre_export
log = logger.create() 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) email_text = N_("%(book)s send to eReader", book=link)
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name, WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
config.get_mail_settings(), ereader_mail, 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
return _("The requested file could not be read. Maybe wrong permissions?") 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: if config.config_password_policy:
verify = "" verify = ""
if config.config_password_min_length > 0: 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: if config.config_password_number:
verify += "(?=.*?\d)" verify += r"(?=.*?\d)"
if config.config_password_lower: if config.config_password_lower:
verify += "(?=.*?[a-z])" verify += r"(?=.*?[a-z])"
if config.config_password_upper: if config.config_password_upper:
verify += "(?=.*?[A-Z])" verify += r"(?=.*?[A-Z])"
if config.config_password_special: if config.config_password_special:
verify += "(?=.*?[^A-Za-z\s0-9])" verify += r"(?=.*?[^A-Za-z\s0-9])"
match = re.match(verify, check_password) match = re.match(verify, check_password)
if not match: if not match:
raise Exception(_("Password doesn't comply with password validation rules")) 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 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_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] binaries_executable = [os.access(binary_path, os.X_OK) for binary_path in supported_binary_paths]
if all(binaries_available) and all(binaries_executable): 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] for binary_path in supported_binary_paths]
if all(values): if all(values):
version = values[0].group(1) version = values[0].group(1)
@ -1149,17 +1122,6 @@ def get_download_link(book_id, book_format, client):
abort(404) 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): def clear_cover_thumbnail_cache(book_id):
if config.schedule_generate_book_covers: if config.schedule_generate_book_covers:
WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True) WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True)

View File

@ -156,6 +156,9 @@ def requires_kobo_auth(f):
limiter.check() limiter.check()
except RateLimitExceeded: except RateLimitExceeded:
return abort(429) return abort(429)
except (ConnectionError, Exception) as e:
log.error("Connection error to limiter backend: %s", e)
return abort(429)
user = ( user = (
ub.session.query(ub.User) ub.session.query(ub.User)
.join(ub.RemoteAuthToken) .join(ub.RemoteAuthToken)

View File

@ -44,9 +44,9 @@ def remove_prefix(text, prefix):
return "" return ""
def redirect_back(endpoint, **values): def get_redirect_location(next, endpoint, **values):
target = request.form.get('next', None) or url_for(endpoint, **values) target = next or url_for(endpoint, **values)
adapter = current_app.url_map.bind(urlparse(request.host_url).netloc) 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',"")))): if not len(adapter.allowed_methods(remove_prefix(target, request.environ.get('HTTP_X_SCRIPT_NAME',"")))):
target = url_for(endpoint, **values) target = url_for(endpoint, **values)
return redirect(target) return target

View File

@ -266,3 +266,6 @@ class CalibreTask:
def _handleSuccess(self): def _handleSuccess(self):
self.stat = STAT_FINISH_SUCCESS self.stat = STAT_FINISH_SUCCESS
self.progress = 1 self.progress = 1
def __str__(self):
return self.name

View File

@ -20,7 +20,7 @@ function getPath() {
return jsFileLocation.substr(0, jsFileLocation.search("/static/js/libs/jquery.min.js")); // the js folder path 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(); event.preventDefault();
var newForm = jQuery('<form>', { var newForm = jQuery('<form>', {
"action": action, "action": action,
@ -30,7 +30,14 @@ function postButton(event, action){
'name': 'csrf_token', 'name': 'csrf_token',
'value': $("input[name=\'csrf_token\']").val(), 'value': $("input[name=\'csrf_token\']").val(),
'type': 'hidden' 'type': 'hidden'
})).appendTo('body'); })).appendTo('body')
if(location !== "") {
newForm.append(jQuery('<input>', {
'name': 'location',
'value': location,
'type': 'hidden'
})).appendTo('body');
}
newForm.submit(); newForm.submit();
} }
@ -212,17 +219,20 @@ $("#delete_confirm").click(function(event) {
$( ".navbar" ).after( '<div class="row-fluid text-center" >' + $( ".navbar" ).after( '<div class="row-fluid text-center" >' +
'<div id="flash_'+item.type+'" class="alert alert-'+item.type+'">'+item.message+'</div>' + '<div id="flash_'+item.type+'" class="alert alert-'+item.type+'">'+item.message+'</div>' +
'</div>'); '</div>');
} }
}); });
$("#books-table").bootstrapTable("refresh"); $("#books-table").bootstrapTable("refresh");
} }
}); });
} else { } 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 //triggered when modal is about to be shown
@ -541,6 +551,7 @@ $(function() {
$.get(e.relatedTarget.href).done(function(content) { $.get(e.relatedTarget.href).done(function(content) {
$modalBody.html(content); $modalBody.html(content);
preFilters.remove(useCache); preFilters.remove(useCache);
$("#back").remove();
}); });
}) })
.on("hidden.bs.modal", function() { .on("hidden.bs.modal", function() {

View File

@ -110,6 +110,7 @@ class TaskConvert(CalibreTask):
self.ereader_mail, self.ereader_mail,
EmailText, EmailText,
self.settings['body'], self.settings['body'],
id=self.book_id,
internal=True) internal=True)
) )
except Exception as ex: except Exception as ex:
@ -315,9 +316,9 @@ class TaskConvert(CalibreTask):
def __str__(self): def __str__(self):
if self.ereader_mail: 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: else:
return "Convert {}".format(self.book_id) return "Convert Book {}".format(self.book_id)
@property @property
def is_cancellable(self): def is_cancellable(self):

View File

@ -28,12 +28,11 @@ from email.message import EmailMessage
from email.utils import formatdate, parseaddr from email.utils import formatdate, parseaddr
from email.generator import Generator from email.generator import Generator
from flask_babel import lazy_gettext as N_ from flask_babel import lazy_gettext as N_
from email.utils import formatdate
from cps.services.worker import CalibreTask from cps.services.worker import CalibreTask
from cps.services import gmail from cps.services import gmail
from cps.embed_helper import do_calibre_export
from cps import logger, config from cps import logger, config
from cps import gdriveutils from cps import gdriveutils
import uuid import uuid
@ -110,7 +109,7 @@ class EmailSSL(EmailBase, smtplib.SMTP_SSL):
class TaskEmail(CalibreTask): 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) super(TaskEmail, self).__init__(task_message)
self.subject = subject self.subject = subject
self.attachment = attachment self.attachment = attachment
@ -119,6 +118,7 @@ class TaskEmail(CalibreTask):
self.recipient = recipient self.recipient = recipient
self.text = text self.text = text
self.asyncSMTP = None self.asyncSMTP = None
self.book_id = id
self.results = dict() self.results = dict()
# from calibre code: # from calibre code:
@ -141,7 +141,7 @@ class TaskEmail(CalibreTask):
message['To'] = self.recipient message['To'] = self.recipient
message['Subject'] = self.subject message['Subject'] = self.subject
message['Date'] = formatdate(localtime=True) 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") message.set_content(self.text.encode('UTF-8'), "text", "plain")
if self.attachment: if self.attachment:
data = self._get_attachment(self.filepath, self.attachment) data = self._get_attachment(self.filepath, self.attachment)
@ -161,6 +161,8 @@ class TaskEmail(CalibreTask):
try: try:
# create MIME message # create MIME message
msg = self.prepare_message() msg = self.prepare_message()
if not msg:
return
if self.settings['mail_server_type'] == 0: if self.settings['mail_server_type'] == 0:
self.send_standard_email(msg) self.send_standard_email(msg)
else: else:
@ -236,10 +238,10 @@ class TaskEmail(CalibreTask):
self.asyncSMTP = None self.asyncSMTP = None
self._progress = x self._progress = x
@classmethod def _get_attachment(self, book_path, filename):
def _get_attachment(cls, book_path, filename):
"""Get file as MIMEBase message""" """Get file as MIMEBase message"""
calibre_path = config.get_book_path() calibre_path = config.get_book_path()
extension = os.path.splitext(filename)[1][1:]
if config.config_use_google_drive: if config.config_use_google_drive:
df = gdriveutils.getFileFromEbooksFolder(book_path, filename) df = gdriveutils.getFileFromEbooksFolder(book_path, filename)
if df: if df:
@ -249,15 +251,22 @@ class TaskEmail(CalibreTask):
df.GetContentFile(datafile) df.GetContentFile(datafile)
else: else:
return None return None
file_ = open(datafile, 'rb') if config.config_binariesdir and config.config_embed_metadata:
data = file_.read() data_path, data_file = do_calibre_export(self.book_id, extension)
file_.close() datafile = os.path.join(data_path, data_file + "." + extension)
with open(datafile, 'rb') as file_:
data = file_.read()
os.remove(datafile) os.remove(datafile)
else: else:
datafile = os.path.join(calibre_path, book_path, filename)
try: try:
file_ = open(os.path.join(calibre_path, book_path, filename), 'rb') if config.config_binariesdir and config.config_embed_metadata:
data = file_.read() data_path, data_file = do_calibre_export(self.book_id, extension)
file_.close() 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: except IOError as e:
log.error_or_exception(e, stacklevel=3) log.error_or_exception(e, stacklevel=3)
log.error('The requested file could not be read. Maybe wrong permissions?') log.error('The requested file could not be read. Maybe wrong permissions?')

View File

@ -32,7 +32,7 @@
</div> </div>
<div class="row display-flex"> <div class="row display-flex">
{% for entry in entries %} {% for entry in entries %}
<div id="books" class="col-sm-3 col-lg-2 col-xs-6 book"> <div id="books" class="col-sm-3 col-lg-2 col-xs-6 book session">
<div class="cover"> <div class="cover">
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}> <a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
<span class="img" title="{{entry.Books.title}}"> <span class="img" title="{{entry.Books.title}}">
@ -99,7 +99,7 @@
<h3>{{_("More by")}} {{ author.name.replace('|',',') }}</h3> <h3>{{_("More by")}} {{ author.name.replace('|',',') }}</h3>
<div class="row"> <div class="row">
{% for entry in other_books %} {% for entry in other_books %}
<div class="col-sm-3 col-lg-2 col-xs-6 book"> <div class="col-sm-3 col-lg-2 col-xs-6 book session">
<div class="cover"> <div class="cover">
<a href="https://www.goodreads.com/book/show/{{ entry.gid['#text'] }}" target="_blank" rel="noopener"> <a href="https://www.goodreads.com/book/show/{{ entry.gid['#text'] }}" target="_blank" rel="noopener">
<img title="{{entry.title}}" src="{{ entry.image_url }}" /> <img title="{{entry.title}}" src="{{ entry.image_url }}" />

View File

@ -18,7 +18,7 @@
</div> </div>
<div class="form-group required"> <div class="form-group required">
<input type="checkbox" id="config_calibre_split" name="config_calibre_split" data-control="split_settings" data-t ="{{ config.config_calibre_split_dir }}" {% if config.config_calibre_split %}checked{% endif %} > <input type="checkbox" id="config_calibre_split" name="config_calibre_split" data-control="split_settings" data-t ="{{ config.config_calibre_split_dir }}" {% if config.config_calibre_split %}checked{% endif %} >
<label for="config_calibre_split">{{_('Separate Book files from Library (Experimental, may lead to unexpected behavior)')}}</label> <label for="config_calibre_split">{{_('Separate Book Files from Library')}}</label>
</div> </div>
<div data-related="split_settings"> <div data-related="split_settings">
<div class="form-group required input-group"> <div class="form-group required input-group">

12
cps/templates/config_edit.html Normal file → Executable file
View File

@ -105,7 +105,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="checkbox" id="config_embed_metadata" name="config_embed_metadata" {% if config.config_embed_metadata %}checked{% endif %}> <input type="checkbox" id="config_embed_metadata" name="config_embed_metadata" {% if config.config_embed_metadata %}checked{% endif %}>
<label for="config_embed_metadata">{{_('Embed Metadata to Ebook File on Download and Conversion (needs Calibre/Kepubify binaries)')}}</label> <label for="config_embed_metadata">{{_('Embed Metadata to Ebook File on Download/Conversion/e-mail (needs Calibre/Kepubify binaries)')}}</label>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="checkbox" id="config_uploading" data-control="upload_settings" name="config_uploading" {% if config.config_uploading %}checked{% endif %}> <input type="checkbox" id="config_uploading" data-control="upload_settings" name="config_uploading" {% if config.config_uploading %}checked{% endif %}>
@ -372,6 +372,16 @@
<input type="checkbox" id="config_ratelimiter" name="config_ratelimiter" {% if config.config_ratelimiter %}checked{% endif %}> <input type="checkbox" id="config_ratelimiter" name="config_ratelimiter" {% if config.config_ratelimiter %}checked{% endif %}>
<label for="config_ratelimiter">{{_('Limit failed login attempts')}}</label> <label for="config_ratelimiter">{{_('Limit failed login attempts')}}</label>
</div> </div>
<div data-related="ratelimiter_settings">
<div class="form-group" style="margin-left:10px;">
<label for="config_calibre">{{_('Configure Backend for Limiter')}}</label>
<input type="text" class="form-control" id="config_limiter_uri" name="config_limiter_uri" value="{% if config.config_limiter_uri != None %}{{ config.config_limiter_uri }}{% endif %}" autocomplete="off">
</div>
<div class="form-group" style="margin-left:10px;">
<label for="config_calibre">{{_('Options for Limiter')}}</label>
<input type="text" class="form-control" id="config_limiter_options" name="config_limiter_options" value="{% if config.config_limiter_options != None %}{{ config.config_limiter_options }}{% endif %}" autocomplete="off">
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="config_session">{{_('Session protection')}}</label> <label for="config_session">{{_('Session protection')}}</label>
<select name="config_session" id="config_session" class="form-control"> <select name="config_session" id="config_session" class="form-control">

7
cps/templates/detail.html Executable file → Normal file
View File

@ -333,15 +333,15 @@
{% endif %} {% endif %}
{% if current_user.role_edit() %} {% if current_user.role_edit() %}
<div class="btn-toolbar" role="toolbar"> <div class="col-sm-12">
<div class="btn-group" role="group" aria-label="Edit/Delete book"> <div class="btn-group" role="group" aria-label="Edit/Delete book">
<a href="{{ url_for('edit-book.show_edit_book', book_id=entry.id) }}" <a href="{{ url_for('edit-book.show_edit_book', book_id=entry.id) }}"
class="btn btn-sm btn-primary" id="edit_book" role="button"><span class="btn btn-sm btn-primary" id="edit_book" role="button"><span
class="glyphicon glyphicon-edit"></span> {{ _('Edit Metadata') }}</a> class="glyphicon glyphicon-edit"></span> {{ _('Edit Metadata') }}</a>
</div> </div>
</div> <div class="btn btn-default" data-back="{{ url_for('web.index') }}" id="back">{{_('Cancel')}}</div>
</div>
{% endif %} {% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -367,4 +367,3 @@
</script> </script>
{% endblock %} {% endblock %}

View File

@ -6,7 +6,7 @@
<h2 class="random-books">{{_('Discover (Random Books)')}}</h2> <h2 class="random-books">{{_('Discover (Random Books)')}}</h2>
<div class="row display-flex"> <div class="row display-flex">
{% for entry in random %} {% for entry in random %}
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books_rand"> <div class="col-sm-3 col-lg-2 col-xs-6 book session" id="books_rand">
<div class="cover"> <div class="cover">
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}> <a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
<span class="img" title="{{ entry.Books.title }}"> <span class="img" title="{{ entry.Books.title }}">
@ -89,7 +89,7 @@
<div class="row display-flex"> <div class="row display-flex">
{% if entries[0] %} {% if entries[0] %}
{% for entry in entries %} {% for entry in entries %}
<div class="col-sm-3 col-lg-2 col-xs-6 book" id="books"> <div class="col-sm-3 col-lg-2 col-xs-6 book session" id="books">
<div class="cover"> <div class="cover">
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}> <a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
<span class="img" title="{{ entry.Books.title }}"> <span class="img" title="{{ entry.Books.title }}">

View File

@ -41,7 +41,7 @@
<div class="row display-flex"> <div class="row display-flex">
{% for entry in entries %} {% for entry in entries %}
<div class="col-sm-3 col-lg-2 col-xs-6 book"> <div class="col-sm-3 col-lg-2 col-xs-6 book session">
<div class="cover"> <div class="cover">
{% if entry.Books.has_cover is defined %} {% if entry.Books.has_cover is defined %}
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}> <a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>

View File

@ -31,7 +31,7 @@
{% endif %} {% endif %}
<div class="row display-flex"> <div class="row display-flex">
{% for entry in entries %} {% for entry in entries %}
<div class="col-sm-3 col-lg-2 col-xs-6 book"> <div class="col-sm-3 col-lg-2 col-xs-6 book session">
<div class="cover"> <div class="cover">
<a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}> <a href="{{ url_for('web.show_book', book_id=entry.Books.id) }}" {% if simple==false %}data-toggle="modal" data-target="#bookDetailsModal" data-remote="false"{% endif %}>
<span class="img" title="{{entry.Books.title}}" > <span class="img" title="{{entry.Books.title}}" >

View File

@ -50,7 +50,7 @@ from .helper import check_valid_domain, check_email, check_username, \
send_registration_mail, check_send_to_ereader, check_read_formats, tags_filters, reset_password, valid_email, \ send_registration_mail, check_send_to_ereader, check_read_formats, tags_filters, reset_password, valid_email, \
edit_book_read_status, valid_password edit_book_read_status, valid_password
from .pagination import Pagination from .pagination import Pagination
from .redirect import redirect_back from .redirect import get_redirect_location
from .babel import get_available_locale from .babel import get_available_locale
from .usermanagement import login_required_if_no_ano from .usermanagement import login_required_if_no_ano
from .kobo_sync_status import remove_synced_book from .kobo_sync_status import remove_synced_book
@ -1276,6 +1276,10 @@ def register_post():
except RateLimitExceeded: except RateLimitExceeded:
flash(_(u"Please wait one minute to register next user"), category="error") flash(_(u"Please wait one minute to register next user"), category="error")
return render_title_template('register.html', config=config, title=_("Register"), page="register") 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: if current_user is not None and current_user.is_authenticated:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not config.get_mail_server_configured(): if not config.get_mail_server_configured():
@ -1338,7 +1342,7 @@ def handle_login_user(user, remember, message, category):
ub.store_user_session() ub.store_user_session()
flash(message, category=category) flash(message, category=category)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits] [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
return redirect_back("web.index") return redirect(get_redirect_location(request.form.get('next', None), "web.index"))
def render_login(username="", password=""): def render_login(username="", password=""):
@ -1374,7 +1378,11 @@ def login_post():
try: try:
limiter.check() limiter.check()
except RateLimitExceeded: except RateLimitExceeded:
flash(_(u"Please wait one minute before next login"), category="error") 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", "")) return render_login(username, form.get("password", ""))
if current_user is not None and current_user.is_authenticated: if current_user is not None and current_user.is_authenticated:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))

View File

@ -1,23 +1,23 @@
# GDrive Integration # GDrive Integration
google-api-python-client>=1.7.11,<2.108.0 google-api-python-client>=1.7.11,<2.120.0
gevent>20.6.0,<24.0.0 gevent>20.6.0,<24.3.0
greenlet>=0.4.17,<3.1.0 greenlet>=0.4.17,<3.1.0
httplib2>=0.9.2,<0.23.0 httplib2>=0.9.2,<0.23.0
oauth2client>=4.0.0,<4.1.4 oauth2client>=4.0.0,<4.1.4
uritemplate>=3.0.0,<4.2.0 uritemplate>=3.0.0,<4.2.0
pyasn1-modules>=0.0.8,<0.4.0 pyasn1-modules>=0.0.8,<0.4.0
pyasn1>=0.1.9,<0.6.0 pyasn1>=0.1.9,<0.6.0
PyDrive2>=1.3.1,<1.18.0 PyDrive2>=1.3.1,<1.20.0
PyYAML>=3.12,<6.1 PyYAML>=3.12,<6.1
rsa>=3.4.2,<4.10.0 rsa>=3.4.2,<4.10.0
# Gmail # Gmail
google-auth-oauthlib>=0.4.3,<1.1.0 google-auth-oauthlib>=0.4.3,<1.3.0
google-api-python-client>=1.7.11,<2.108.0 google-api-python-client>=1.7.11,<2.120.0
# goodreads # goodreads
goodreads>=0.3.2,<0.4.0 goodreads>=0.3.2,<0.4.0
python-Levenshtein>=0.12.0,<0.22.0 python-Levenshtein>=0.12.0,<0.26.0
# ldap login # ldap login
python-ldap>=3.0.0,<3.5.0 python-ldap>=3.0.0,<3.5.0
@ -28,10 +28,10 @@ Flask-Dance>=2.0.0,<7.1.0
SQLAlchemy-Utils>=0.33.5,<0.42.0 SQLAlchemy-Utils>=0.33.5,<0.42.0
# metadata extraction # metadata extraction
rarfile>=3.2 rarfile>=3.2,<4.2
scholarly>=1.2.0,<1.8 scholarly>=1.2.0,<1.8
markdown2>=2.0.0,<2.5.0 markdown2>=2.0.0,<2.5.0
html2text>=2020.1.16,<2022.1.1 html2text>=2020.1.16,<2024.2.26
python-dateutil>=2.1,<2.9.0 python-dateutil>=2.1,<2.9.0
beautifulsoup4>=4.0.1,<4.13.0 beautifulsoup4>=4.0.1,<4.13.0
faust-cchardet>=2.1.18,<2.1.20 faust-cchardet>=2.1.18,<2.1.20

View File

@ -2,18 +2,18 @@ Werkzeug<3.0.0
APScheduler>=3.6.3,<3.11.0 APScheduler>=3.6.3,<3.11.0
Babel>=1.3,<3.0 Babel>=1.3,<3.0
Flask-Babel>=0.11.1,<4.1.0 Flask-Babel>=0.11.1,<4.1.0
Flask-Login>=0.3.2,<0.6.3 Flask-Login>=0.3.2,<0.6.4
Flask-Principal>=0.3.2,<0.5.1 Flask-Principal>=0.3.2,<0.5.1
Flask>=1.0.2,<2.4.0 Flask>=1.0.2,<3.1.0
iso-639>=0.4.5,<0.5.0 iso-639>=0.4.5,<0.5.0
PyPDF>=3.0.0,<3.16.0 PyPDF>=3.15.6,<4.1.0
pytz>=2016.10 pytz>=2016.10
requests>=2.28.0,<2.32.0 requests>=2.28.0,<2.32.0
SQLAlchemy>=1.3.0,<2.1.0 SQLAlchemy>=1.3.0,<2.1.0
tornado>=6.3,<6.4 tornado>=6.3,<6.5
Wand>=0.4.4,<0.7.0 Wand>=0.4.4,<0.7.0
unidecode>=0.04.19,<1.4.0 unidecode>=0.04.19,<1.4.0
lxml>=3.8.0,<5.0.0 lxml>=3.8.0,<5.2.0
flask-wtf>=0.14.2,<1.3.0 flask-wtf>=0.14.2,<1.3.0
chardet>=3.0.0,<4.1.0 chardet>=3.0.0,<4.1.0
advocate>=1.0.0,<1.1.0 advocate>=1.0.0,<1.1.0

View File

@ -42,18 +42,18 @@ install_requires =
APScheduler>=3.6.3,<3.11.0 APScheduler>=3.6.3,<3.11.0
Babel>=1.3,<3.0 Babel>=1.3,<3.0
Flask-Babel>=0.11.1,<4.1.0 Flask-Babel>=0.11.1,<4.1.0
Flask-Login>=0.3.2,<0.6.3 Flask-Login>=0.3.2,<0.6.4
Flask-Principal>=0.3.2,<0.5.1 Flask-Principal>=0.3.2,<0.5.1
Flask>=1.0.2,<2.4.0 Flask>=1.0.2,<3.1.0
iso-639>=0.4.5,<0.5.0 iso-639>=0.4.5,<0.5.0
PyPDF>=3.0.0,<3.16.0 PyPDF>=3.15.6,<4.1.0
pytz>=2016.10 pytz>=2016.10
requests>=2.28.0,<2.32.0 requests>=2.28.0,<2.32.0
SQLAlchemy>=1.3.0,<2.1.0 SQLAlchemy>=1.3.0,<2.1.0
tornado>=6.3,<6.4 tornado>=6.3,<6.5
Wand>=0.4.4,<0.7.0 Wand>=0.4.4,<0.7.0
unidecode>=0.04.19,<1.4.0 unidecode>=0.04.19,<1.4.0
lxml>=3.8.0,<5.0.0 lxml>=3.8.0,<5.2.0
flask-wtf>=0.14.2,<1.3.0 flask-wtf>=0.14.2,<1.3.0
chardet>=3.0.0,<4.1.0 chardet>=3.0.0,<4.1.0
advocate>=1.0.0,<1.1.0 advocate>=1.0.0,<1.1.0
@ -66,23 +66,23 @@ include = cps/services*
[options.extras_require] [options.extras_require]
gdrive = gdrive =
google-api-python-client>=1.7.11,<2.108.0 google-api-python-client>=1.7.11,<2.120.0
gevent>20.6.0,<24.0.0 gevent>20.6.0,<24.3.0
greenlet>=0.4.17,<3.1.0 greenlet>=0.4.17,<3.1.0
httplib2>=0.9.2,<0.23.0 httplib2>=0.9.2,<0.23.0
oauth2client>=4.0.0,<4.1.4 oauth2client>=4.0.0,<4.1.4
uritemplate>=3.0.0,<4.2.0 uritemplate>=3.0.0,<4.2.0
pyasn1-modules>=0.0.8,<0.4.0 pyasn1-modules>=0.0.8,<0.4.0
pyasn1>=0.1.9,<0.6.0 pyasn1>=0.1.9,<0.6.0
PyDrive2>=1.3.1,<1.18.0 PyDrive2>=1.3.1,<1.20.0
PyYAML>=3.12,<6.1 PyYAML>=3.12,<6.1
rsa>=3.4.2,<4.10.0 rsa>=3.4.2,<4.10.0
gmail = gmail =
google-auth-oauthlib>=0.4.3,<1.1.0 google-auth-oauthlib>=0.4.3,<1.3.0
google-api-python-client>=1.7.11,<2.108.0 google-api-python-client>=1.7.11,<2.120.0
goodreads = goodreads =
goodreads>=0.3.2,<0.4.0 goodreads>=0.3.2,<0.4.0
python-Levenshtein>=0.12.0,<0.22.0 python-Levenshtein>=0.12.0,<0.26.0
ldap = ldap =
python-ldap>=3.0.0,<3.5.0 python-ldap>=3.0.0,<3.5.0
Flask-SimpleLDAP>=1.4.0,<1.5.0 Flask-SimpleLDAP>=1.4.0,<1.5.0
@ -90,10 +90,10 @@ oauth =
Flask-Dance>=2.0.0,<7.1.0 Flask-Dance>=2.0.0,<7.1.0
SQLAlchemy-Utils>=0.33.5,<0.42.0 SQLAlchemy-Utils>=0.33.5,<0.42.0
metadata = metadata =
rarfile>=3.2 rarfile>=3.2,<4.2
scholarly>=1.2.0,<1.8 scholarly>=1.2.0,<1.8
markdown2>=2.0.0,<2.5.0 markdown2>=2.0.0,<2.5.0
html2text>=2020.1.16,<2022.1.1 html2text>=2020.1.16,<2024.2.26
python-dateutil>=2.1,<2.9.0 python-dateutil>=2.1,<2.9.0
beautifulsoup4>=4.0.1,<4.13.0 beautifulsoup4>=4.0.1,<4.13.0
faust-cchardet>=2.1.18,<2.1.20 faust-cchardet>=2.1.18,<2.1.20

File diff suppressed because it is too large Load Diff