Merge branch 'master' into Develop

This commit is contained in:
Ozzie Isaacs
2026-05-09 10:17:48 +02:00
80 changed files with 8465 additions and 6334 deletions
+2 -1
View File
@@ -349,8 +349,9 @@ def list_users():
if sort not in ub.User.__table__.columns.keys():
sort = "id"
order = request.args.get("order", "").lower()
if sort != "state" and order:
if not order in ["asc", "desc"]:
order = "asc"
order = text(sort + " " + order)
elif not state:
order = ub.User.id.asc()
-1
View File
@@ -19,7 +19,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from cps.pagination import Pagination
from flask import Blueprint
from flask_babel import gettext as _
from flask_babel import get_locale
+4 -1
View File
@@ -326,7 +326,9 @@ class ConfigSQL(object):
def to_dict(self):
storage = {}
for k, v in self.__dict__.items():
if k[0] != '_' and not k.endswith("_e") and not k == "cli" and 'api' not in k.lower():
if k[0] != '_' and not k.endswith("_e") and not k == "cli" \
and 'api' not in k.lower() and 'token' not in k.lower() \
and 'secret' not in k.lower():
storage[k] = v
return storage
@@ -583,6 +585,7 @@ def get_encryption_key(key_path):
try:
with open(key_file, "wb") as f:
f.write(key)
os.chmod(key_file, 0o600)
except PermissionError as e:
error = e
return key, error
+13 -6
View File
@@ -449,7 +449,14 @@ class Books(Base):
@property
def atom_timestamp(self):
return self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or ''
# OPDS atom:updated is defined as "the most recent instant in time
# when the entry was modified". Books.timestamp is the date added and
# never changes after import, so metadata and cover edits were
# invisible to OPDS sync clients. Use last_modified, which Calibre
# updates on every metadata or cover change; fall back to timestamp
# only if last_modified happens to be missing.
t = self.last_modified or self.timestamp
return t.strftime('%Y-%m-%dT%H:%M:%S+00:00') if t else ''
class CustomColumns(Base):
@@ -640,8 +647,8 @@ class CalibreDB:
connect_args={'check_same_thread': False},
poolclass=StaticPool)
with check_engine.begin() as connection:
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
connection.execute(text("attach database '{}' as calibre;".format(dbpath.replace("'", "''"))))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path.replace("'", "''"))))
local_session = scoped_session(sessionmaker())
local_session.configure(bind=connection)
database_uuid = local_session().query(Library_Id).one_or_none()
@@ -694,8 +701,8 @@ class CalibreDB:
poolclass=StaticPool)
with engine.begin() as connection:
connection.execute(text('PRAGMA cache_size = 10000;'))
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
connection.execute(text("attach database '{}' as calibre;".format(dbpath.replace("'", "''"))))
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path.replace("'", "''"))))
conn = engine.connect()
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
@@ -1108,7 +1115,7 @@ class CalibreDB:
.group_by(text('books_languages_link.lang_code')).all()
tags = list()
for lang in languages:
tag = Category(isoLanguages.get_language_name(get_locale(), None, lang[0].lang_code), lang[0].lang_code)
tag = Category(isoLanguages.get_language_name(get_locale(), lang[0].lang_code), lang[0].lang_code)
tags.append([tag, lang[1]])
# Append all books without language to list
if not return_all_languages:
+2 -1
View File
@@ -36,7 +36,8 @@ def do_calibre_export(book_id, book_format):
if config.config_calibre_split:
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
library_path = config.get_book_path()
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path,
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--dont-save-cover',
'--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)
+1 -1
View File
@@ -175,7 +175,7 @@ def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path):
for cs in cover_section:
if cs.endswith('.xhtml') or cs.endswith('.html'):
markup = epub_zip.read(os.path.join(cover_path, cs))
markup_tree = etree.fromstring(markup)
markup_tree = etree.fromstring(markup, parser=etree.XMLParser(resolve_entities=False, no_network=True))
# no matter xhtml or html with no namespace
img_src = markup_tree.xpath("//*[local-name() = 'img']/@src")
# Alternative image source
+6 -2
View File
@@ -53,16 +53,20 @@ def updateEpub(src, dest, filename, data, ):
zf.writestr(filename, data)
# Safe parser: disable entity resolution and network access to prevent XXE attacks
_safe_parser = etree.XMLParser(resolve_entities=False, no_network=True)
def get_content_opf(file_path, ns=None):
if ns is None:
ns = default_ns
epubZip = zipfile.ZipFile(file_path)
txt = epubZip.read('META-INF/container.xml')
tree = etree.fromstring(txt)
tree = etree.fromstring(txt, parser=_safe_parser)
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
cf = epubZip.read(cf_name)
return etree.fromstring(cf), cf_name
return etree.fromstring(cf, parser=_safe_parser), cf_name
def create_new_metadata_backup(book, custom_columns, export_language, translated_cover_name, lang_type=3):
+10 -1
View File
@@ -22,6 +22,8 @@ from flask import render_template, request, flash, make_response
from flask_limiter import RateLimitExceeded
from flask_babel import gettext as _
from werkzeug.exceptions import default_exceptions
from .cw_login import current_user
try:
from werkzeug.exceptions import FailedDependency
except ImportError:
@@ -62,6 +64,13 @@ def internal_error(error):
error_stack="",
instance=config.config_calibre_web_title
), 500
log.error("500 Internal Server Error: %s", traceback.format_exc())
error_stack = ""
try:
if current_user.is_authenticated and current_user.role_admin():
error_stack = traceback.format_exc().split("\n")
except Exception:
pass
return render_template('http_error.html',
error_code="500 Internal Server Error",
error_name='The server encountered an internal error and was unable to complete your '
@@ -69,7 +78,7 @@ def internal_error(error):
issue=True,
goto_admin=False,
unconfigured=False,
error_stack=traceback.format_exc().split("\n"),
error_stack=error_stack,
instance=config.config_calibre_web_title
), 500
+4 -1
View File
@@ -20,6 +20,9 @@ from lxml import etree
from .constants import BookMeta
# Safe parser: disable entity resolution and network access to prevent XXE attacks
_safe_parser = etree.XMLParser(resolve_entities=False, no_network=True)
def get_fb2_info(tmp_file_path, original_file_extension):
@@ -29,7 +32,7 @@ def get_fb2_info(tmp_file_path, original_file_extension):
}
fb2_file = open(tmp_file_path, encoding="utf-8")
tree = etree.fromstring(fb2_file.read().encode())
tree = etree.fromstring(fb2_file.read().encode(), parser=_safe_parser)
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)
+1 -1
View File
@@ -140,7 +140,7 @@ try:
if response:
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
and response['file']['md5Checksum'] != hashlib.md5(dbpath).hexdigest(): # nosec
tmp_dir = get_temp_dir()
log.info('Database file updated')
+12 -1
View File
@@ -30,7 +30,7 @@ import requests
import unidecode
from uuid import uuid4
from flask import send_from_directory, make_response, abort, url_for, Response, request
from flask import send_from_directory, make_response, abort, url_for, Response, request, after_this_request
from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_
from flask_babel import get_locale
@@ -956,6 +956,17 @@ def do_download_file(book, book_format, client, data, headers):
else:
download_name = book_name
# Clean up staged copies in /tmp/calibre_web after the response is sent
# (kepubify / calibre-export branches) so the temp dir does not grow unbounded.
if filename == get_temp_dir():
_tmp_path = os.path.join(filename, download_name + "." + book_format)
@after_this_request
def _cleanup_staged_download(resp):
try:
os.remove(_tmp_path)
except OSError as ex:
log.warning('Failed to remove staged download %s: %s', _tmp_path, ex)
return resp
response = make_response(send_from_directory(filename, download_name + "." + book_format))
# ToDo Check headers parameter
for element in headers:
+4
View File
@@ -81,6 +81,8 @@ kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
@kobo_auth.route("/generate_auth_token/<int:user_id>")
@user_login_required
def generate_auth_token(user_id):
if current_user.id != user_id and not current_user.role_admin():
abort(403)
warning = False
host_list = request.host.rsplit(':')
if len(host_list) == 1:
@@ -123,6 +125,8 @@ def generate_auth_token(user_id):
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
@user_login_required
def delete_auth_token(user_id):
if current_user.id != user_id and not current_user.role_admin():
abort(403)
# Invalidate any previously generated Kobo Auth token for this user
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
.filter(ub.RemoteAuthToken.token_type==1).delete()
+1 -1
View File
@@ -57,7 +57,7 @@ class ComicVine(Metadata):
result.raise_for_status()
except Exception as e:
log.warning(e)
return None
return []
for result in result.json()["results"]:
match = self._parse_search_result(
result=result, generic_cover=generic_cover, locale=locale
+1 -1
View File
@@ -155,7 +155,7 @@ class Douban(Metadata):
r.raise_for_status()
except Exception as e:
log.warning(e)
return None
return []
match = MetaRecord(
id=id,
+8
View File
@@ -134,6 +134,14 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
oauth_entry = query.first()
# already bind with user, just login
if oauth_entry.user:
# If a user is already logged in and it's a different account, reject the link
# to prevent account takeover via shared OAuth identities
if current_user and current_user.is_authenticated and oauth_entry.user_id != current_user.id:
flash(_("This %(oauth)s account is already linked to a different user",
oauth=provider_name), category="error")
log.warning("User %s tried to link OAuth account already bound to user %s",
current_user.id, oauth_entry.user_id)
return redirect(url_for('web.profile'))
login_user(oauth_entry.user)
log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
flash(_("Success! You are now logged in as: %(nickname)s", nickname=oauth_entry.user.name),
+1 -1
View File
@@ -92,7 +92,7 @@ class my_GoodreadsRequest(GoodreadsRequest):
if resp.status_code != 200:
raise GoodreadsRequestException(resp.reason, self.path)
if self.req_format == 'xml':
root = etree.fromstring(resp.content)
root = etree.fromstring(resp.content, parser=etree.XMLParser(resolve_entities=False, no_network=True))
data_dict = etree_to_dict(root)
return data_dict['GoodreadsResponse']
+14 -2
View File
@@ -31,6 +31,16 @@ except ImportError:
log = logger.create()
def _escape_ldap_filter(s):
"""Escape special characters for safe use in LDAP filter strings (RFC 4515)."""
s = s.replace('\\', '\\5c')
s = s.replace('*', '\\2a')
s = s.replace('(', '\\28')
s = s.replace(')', '\\29')
s = s.replace('\x00', '\\00')
return s
class LDAPLogger(object):
@staticmethod
@@ -148,9 +158,11 @@ def bind_user(username, password):
:returns: True if login succeeded, False if login failed, None if server unavailable.
'''
# Escape LDAP special characters to prevent LDAP injection in search filters
safe_username = _escape_ldap_filter(username)
try:
if _ldap.get_object_details(username):
result = _ldap.bind_user(username, password)
if _ldap.get_object_details(safe_username):
result = _ldap.bind_user(safe_username, password)
log.debug("LDAP login '%s': %r", username, result)
return result is not None, None
return None, None # User not found
+3
View File
@@ -308,6 +308,9 @@ 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):
if request.method == "POST":
if not check_shelf_edit_permissions(shelf):
flash(_("Sorry you are not allowed to edit this shelf"), category="error")
return redirect(url_for('web.index'))
to_save = request.form.to_dict()
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by(
ub.BookShelf.order.asc()).all()
+1 -1
View File
@@ -31,7 +31,7 @@
<h2>Details</h2>
{% if entry.series|length > 0 %}
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index | formatfloat(2), range=(entry.series[0].name)|safe) }}</p>
<p>{{ _("Book %(index)s of %(range)s", index=entry.series_index|formatfloat(2), range=entry.series[0].name|e) }}</p>
{% endif %}
{% if entry.languages|length > 0 %}
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -842,7 +842,8 @@ def list_books():
order = request.args.get("order", "").lower()
state = None
join = tuple()
if not order in ["asc", "desc", ""]:
order = "asc"
if sort_param == "state":
state = json.loads(request.args.get("state", "[]"))
elif sort_param == "tags":
@@ -1194,7 +1195,9 @@ def get_robots():
@viewer_required
def serve_book(book_id, book_format, anyname):
book_format = book_format.split(".")[0]
book = calibre_db.get_book(book_id)
book = calibre_db.get_filtered_book(book_id)
if not book:
return "File not in Database"
data = calibre_db.get_book_format(book_id, book_format.upper())
if not data:
return "File not in Database"
+214 -209
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -33,12 +33,12 @@ dependencies = [
"Flask>=1.0.2,<3.2.0",
"iso-639>=0.4.5,<0.5.0;python_version<'3.12'",
"pycountry>=20.0.0,<27.0.0;python_version>='3.12'",
"PyPDF>=6.1.3,<6.8.0",
"PyPDF>=6.1.3,<6.11.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",
"Wand>=0.4.4,<0.8.0",
"unidecode>=0.04.19,<1.5.0",
"lxml>=4.9.1,<5.4.0",
"flask-wtf>=0.14.2,<1.3.0",
@@ -51,7 +51,7 @@ dependencies = [
"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,<47.0.0",
"cryptography>=39.0.0,<48.0.0",
"certifi>=2024.7.4,<2026.1.5",
]
dynamic = ["version"]
+3 -3
View File
@@ -5,12 +5,12 @@ Flask-Principal>=0.3.2,<0.5.1
Flask>=1.0.2,<3.2.0
iso-639>=0.4.5,<0.5.0;python_version<'3.12'
pycountry>=20.0.0,<27.0.0;python_version>='3.12'
PyPDF>=6.1.3,<6.8.0
PyPDF>=6.1.3,<6.11.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
Wand>=0.4.4,<0.8.0
unidecode>=0.04.19,<1.5.0
lxml>=4.9.1,<5.4.0
flask-wtf>=0.14.2,<1.3.0
@@ -23,5 +23,5 @@ 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,<47.0.0
cryptography>=39.0.0,<48.0.0
certifi>=2024.7.4,<2026.1.5
File diff suppressed because it is too large Load Diff