Compare commits

...
93 Commits
Author SHA1 Message Date
Ozzie Isaacs e732b3b614 Output message only in production mode 2026-05-24 10:19:49 +02:00
Ozzie Isaacs 69f8767be5 Fixes from testrun 2026-05-17 10:21:19 +02:00
Ozzie Isaacs 6cbba6d170 Merge remote-tracking branch 'fix/commentxss' into Develop 2026-05-09 10:30:59 +02:00
Ozzie Isaacs 888ab4dc8f Update requirements 2026-05-09 10:28:12 +02:00
Ozzie Isaacs 753c19e700 Merge remote-tracking branch 'py314/py3.14-janeczku' into Develop 2026-05-09 10:24:58 +02:00
Ozzie Isaacs 4f9390332a Merge remote-tracking branch 'fix/attr' into Develop 2026-05-09 10:20:45 +02:00
Ozzie Isaacs ffddd85800 Merge remote-tracking branch 'fix/fixlter' into Develop 2026-05-09 10:19:57 +02:00
Ozzie Isaacs 652b0f20e0 Merge remote-tracking branch 'fix/token' into Develop 2026-05-09 10:17:57 +02:00
Ozzie Isaacs 598f08b916 Merge branch 'master' into Develop 2026-05-09 10:17:48 +02:00
Jacob Chapman 55cedba693 bump lxml to v6 for python 3.14 2026-04-29 22:05:40 -05:00
Jacob Chapman 10caf68d8b fix manifest reference 2026-04-29 22:05:40 -05:00
Ozzie Isaacs f4ed816f6e Fixes from testrun 2026-04-26 07:27:06 +02:00
Ozzie Isaacs ba77f41325 Merge remote-tracking branch 'fixes/notrace' 2026-04-25 16:04:51 +02:00
Ozzie Isaacs ea567fcbf0 Updated translation 2026-04-25 13:26:40 +02:00
Ozzie Isaacs edb05abcbe Merge remote-tracking branch 'tmp-orphan-on-download/fix-calibredb-cover-sibling' 2026-04-25 13:25:34 +02:00
Ozzie Isaacs 46811219ca Updated Requirements 2026-04-25 13:20:25 +02:00
Ozzie Isaacs 0aab0cac73 Merge remote-tracking branch 'tmp-orphan-on-download/fix-tmp-orphan-on-download' 2026-04-25 13:13:36 +02:00
Ozzie Isaacs 3257a36892 Merge remote-tracking branch 'ko/ko-translate-update' 2026-04-25 13:11:14 +02:00
limeade23 f10b680b0e Update Korean language 2026-04-23 17:41:46 +09:00
Ozzie Isaacs c2b1950cd5 Merge remote-tracking branch 'opds_date_update/fix-opds-atom-updated-reflects-modifications' 2026-04-20 15:55:24 +02:00
Ozzie Isaacs 00c8ff8ac4 Merge remote-tracking branch 'metadata-provider-none-crash/fix/metadata-provider-none-crash' 2026-04-20 15:49:26 +02:00
walrusec 0ca6e86177 Fix metadata search crash when ComicVine or Douban return None on error
Both comicvine.py and douban.py return None instead of [] when an HTTP
error occurs during a metadata search. The consumer in search_metadata.py
iterates the result directly, which raises TypeError: 'NoneType' object
is not iterable and crashes the server on single-threaded deployments.

Fixes #3606
2026-04-19 08:54:47 -04:00
haraldpdl ab17992ebb fix(opds): atom:updated reflects last modification, not date added
Books.atom_timestamp returned Books.timestamp (date added), which is
set at import and never changes. OPDS clients use atom:updated to
decide whether a book has changed on the server, so cover swaps,
metadata edits, and any other post-import change were invisible to
sync clients; they would keep serving the stale cover and title
until a manual refresh.

Atom RFC 4287 defines updated as "the most recent instant in time
when an entry or feed was modified", and Calibre already tracks that
field as last_modified, bumping it on every metadata and cover edit.
Switching the property to return last_modified (with a fallback to
timestamp when last_modified is NULL) aligns Calibre-Web's behaviour
with the Atom contract.

This change only affects the OPDS feed's atom:updated element. Kobo
sync uses its own last_modified comparison path, so it is unaffected.
2026-04-19 10:51:45 +02:00
haraldpdl 570371cc82 Don't write a cover sibling per download (--dont-save-cover)
calibredb export writes <uuid>.jpg next to <uuid>.<format> by
default, leaving an unused cover image in /tmp/calibre_web for
every download that goes through do_calibre_export. The cover
file is never read by calibre-web (cover serving uses a separate
path), so it is pure waste of disk space and IO.

Pass --dont-save-cover, paired with the existing --dont-write-opf,
to skip the unwanted side-effect at the source. Verified that
none of the three do_calibre_export call sites (download via
do_download_file, email via tasks/mail.py, kepubify via
tasks/convert.py) read the cover sibling.
2026-04-18 07:27:33 +02:00
haraldpdl 674b47bdbd Remove staged download from /tmp/calibre_web after response is sent
When config_embed_metadata is enabled with config_binariesdir or
config_kepubifypath set, do_download_file stages a copy of every
downloaded book under get_temp_dir() but never removes it. A bulk
OPDS or Kobo sync can fill the host filesystem in minutes; the
effect is amplified for comic formats (CBZ/CBR), where each
staged file can run to hundreds of MB or several GB. Once the
disk fills, downloads silently 404 and a container restart does
not recover the space.

Add an after_this_request hook that removes the staged copy after
the response is sent, gated on filename == get_temp_dir() so the
non-embed and gdrive-direct paths are untouched.
2026-04-18 07:12:38 +02:00
jvoisin 8cff413c4d Clean atributes as well in clean_string
`<img src=x onerror=alert(1)>` womp womp
2026-04-15 23:30:49 +02:00
jvoisin fd744af75d Don't give non-admin users a full stacktrace on 500
As `traceback.format_exc()` might contain internal file paths, library versions,
function names, and variable values.
2026-04-15 23:28:27 +02:00
jvoisin 6208d5e264 Correctly filter results kobo API
Multiple Kobo API endpoints use calibre_db.get_book_by_uuid() at db.py:748-749,
which performs a raw unfiltered query, meaning that common_filters() isn't
applied, so tag-based ACLs, language restrictions, and archived-book filtering
are all bypassed.
2026-04-15 23:14:09 +02:00
jvoisin 42dc36cc10 Escape comment columns names
Custom columns of type comments are rendered with `|safe` (disabling Jinja2
auto-escaping) and no `clean_string` sanitization. Compare with regular book
comments which correctly use `{{ entry.comments[0].text|clean_string|safe }}`.

Any user with edit permissions can set a custom comment column to
`<script>alert(document.cookie)</script>` and it will execute for every user who
views the book detail page or the OPDS feed. This is stored XSS with no
authentication barrier beyond edit permission.
2026-04-15 23:03:30 +02:00
jvoisin c23d35db4a Use 128 bits of entropy instead of only 32 in csp/ub.py
Remote login tokens are generated from only 4 bytes of randomness (32 bits = ~4
billion possibilities, 8 hex characters). The /ajax/verify_token endpoint at
remotelogin.py:98 has no rate limiting. The token is valid for 10 minutes.

At even modest request rates (10,000 req/sec), an attacker can test ~6 million
tokens during the 10-minute window , which isn't enough to exhaust the full
space, sure, but combined with multiple concurrent login sessions (each
generating a new token), or if the attacker can trigger the victim to initiate
remote login, the attack becomes more feasible. Compare with the Kobo auth
token which uses urandom(16) (128 bits).
2026-04-15 22:47:35 +02:00
Ozzie Isaacs fca580505c Fix typo 2026-04-15 20:31:40 +02:00
Ozzie Isaacs 19da54a7ae Merge remote-tracking branch 'fixes/escape_ldap' 2026-04-15 19:37:31 +02:00
Ozzie Isaacs 9c87f0fbde Merge remote-tracking branch 'fixes/fix_gdrive_md5' 2026-04-15 19:36:56 +02:00
Ozzie Isaacs e21f943712 Merge remote-tracking branch 'fixes/oauth_relink' 2026-04-15 19:36:48 +02:00
Ozzie Isaacs 4bb553f51d Merge remote-tracking branch 'fixes/noxxe' 2026-04-15 19:35:40 +02:00
Ozzie Isaacs 0615637f58 Merge remote-tracking branch 'fixes/nodebugleak' 2026-04-15 19:33:06 +02:00
Ozzie Isaacs a6c55deac6 Merge remote-tracking branch 'fixes/accessbp' 2026-04-15 19:31:29 +02:00
Ozzie Isaacs 2de8df7e0d Merge remote-tracking branch 'fixes/fix_kobo' 2026-04-15 19:28:55 +02:00
Ozzie Isaacs 4c1d7a9f6b Merge remote-tracking branch 'fixes/permshelf' 2026-04-15 19:28:12 +02:00
Ozzie Isaacs 9489905602 Merge remote-tracking branch 'fixes/chm' 2026-04-15 19:24:12 +02:00
Ozzie Isaacs 5e7c0c9d34 Merge remote-tracking branch 'fixes/sqlidb' 2026-04-15 19:23:01 +02:00
jvoisin b5da0df42a Prevent SQLI via dbpath
This is reachable only by the admin users, but is still a straightforward RCE
vector, if only via SQLite's `ATTACH DATABASE` trick
2026-04-14 23:23:12 +02:00
jvoisin 0959f84fd5 Use a sane permission for the encryption key file
The typical Linux umask of 0022, meaning the encrypted file is world-readable
(-rw-r--r--). Any OS-level user on the same system can read the key and decrypt
the encrypted credentials from app.db.
2026-04-14 23:08:35 +02:00
jvoisin 84777319d7 Fix access bypass on /show/ (serve_book)
The `serve_book` function uses `get_book()` which performs no access filtering:
it simply fetches by ID. Compare with `read_book` at web.py:1562 which
correctly uses `get_filtered_book()`. The `common_filters()` function enforces
per-user tag restrictions, language restrictions, and hidden-book rules.
2026-04-14 23:05:18 +02:00
jvoisin d85bef6c38 Don't leak credentials in debug_info
No need to dump Gmail OAuth client_secret, refresh_token, and
access_token in the debug ZIP in plaintext.
2026-04-14 22:55:08 +02:00
jvoisin 8ad9f4e3b7 Fix a dumb type condition in gdrive.py
hashlib.md5(dbpath) returns a hash object, not a hex string. Comparing a string
(md5Checksum) to a hash object with != always returns True. This means the
DB-replacement code path is always entered, allowing an attacker who sends a
forged notification (with the known static token) to trigger an arbitrary
metadata.db download from GDrive, replacing the live database.
2026-04-14 22:51:36 +02:00
jvoisin cde3888e17 Prevent LDAP injection in bind_user>get_object_details 2026-04-14 22:35:13 +02:00
jvoisin 387678a771 Prevent OAuth relinking.
When an OAuth provider_user_id is already linked to User A, and User B
authenticates with the same OAuth identity, User B is silently logged in as
User A. This is by design for single-user OAuth, but in a multi-user
environment it means: if an attacker gains access to the same OAuth provider
account (e.g., a shared GitHub org account, or by compromising the OAuth
provider), they can log in as the linked Calibre-Web user with no password
needed.
2026-04-14 22:28:06 +02:00
jvoisin c451daad3c Don't allow users to edit shelves they don't have permission to edit. 2026-04-14 22:25:36 +02:00
jvoisin 224915bba1 Prevent XXE in epub/fb2/goodreads API
The lxml.etree.fromstring() function use the default XML parser, which resolves
external entities because XML handling defaults in Python sucks. There is no
need for such dangerous misfeatures in calibre-web, so let's disable it.

A user able to upload epub/fb2 could add something like this to the file:

```xml
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<container><rootfiles><rootfile full-path="&xxe;"/></rootfiles></container>
```

and obtain the content of the `/etc/passwd` file, which is bad™.
2026-04-14 22:12:57 +02:00
jvoisin d598054195 Fix an IDOR in kobo tokens
Authenticated users shouldn't be able to generate/delete kobo auth tokens for
other users if they're not admin.
2026-04-14 22:10:30 +02:00
Ozzie Isaacs 088778969d Better error handling on series display in basic view 2026-04-06 15:28:10 +02:00
Ozzie Isaacs 0ed4d81da2 Better error handling on order parameter for sorting of user and books table 2026-04-06 15:12:09 +02:00
Ozzie Isaacs 9478325c23 Merge branch 'master' into Develop 2026-04-03 09:09:43 +02:00
Ozzie Isaacs 7c715f34dc Clean strings in comments before displaying 2026-04-03 09:01:01 +02:00
Ozzie Isaacs 00686ec1f2 Merge remote-tracking branch 'page_count/fix-series-list-view-sorts-by-name-instead-of-sort-field' 2026-03-07 10:24:58 +01:00
Ozzie Isaacs 83d331c89b Merge remote-tracking branch 'page_count/fix-kobo-prioritytimestamp-put-response' 2026-03-07 09:55:45 +01:00
Ozzie Isaacs f6338a9fda Merge remote-tracking branch 'filename/master' 2026-03-07 09:46:16 +01:00
Ozzie Isaacs 73efef17bd Merge branch 'master' into Develop 2026-03-07 09:36:19 +01:00
Ozzie Isaacs 74725b68b0 Merge remote-tracking branch 'it/patch-1' 2026-03-07 09:28:14 +01:00
mapi68 efbdd0b6e3 Update italian messages.po 2026-03-05 06:44:48 +01:00
jarynclouatre 1134f54be4 fix: series list view sorts by name instead of sort field
In series_list(), the SQLite query correctly orders results by
Series.sort, but a subsequent Python sorted() call (needed to
re-order after appending the "None" category entry) was using
Series.name as the sort key instead of Series.sort.

This caused series titles with leading articles (A, An, The) to
sort strictly alphabetically by the article rather than by the
meaningful word, e.g. "A Collins-Burke Mystery" appeared under
"A" instead of "C".

Fix by using Series.sort (with a fallback to Series.name if sort
is NULL) as the key in the Python re-sort, consistent with the
intent of the existing DB query.

Fixes #3583
2026-03-01 20:44:05 -06:00
jarynclouatre 2d4ca23d0c Kobo: include PriorityTimestamp in PUT /state response
Kobo: include PriorityTimestamp in PUT /state response
2026-03-01 18:19:40 -06:00
jarynclouatre 6157f5027c Update search_metadata.py
Fix TypeError when metadata provider returns None
2026-03-01 15:44:29 +01:00
Ozzie Isaacs 5e48a64b15 Merge remote-tracking branch 'opds-500-unauthenticated-request/fix/opds-500-unauthenticated-request' 2026-02-22 10:43:11 +01:00
Rafik Farhad 36a7ff19bc Fix AttributeError on unauthenticated OPDS requests
request_username() is used as flask-limiter's key_func for the OPDS
blueprint. The limiter evaluates key_func in a before_request handler,
before the route's auth decorator runs. When no Authorization header is
present, request.authorization is None, causing an AttributeError and
a 500 response instead of the expected 401.

Guard against None so unauthenticated requests fall back to an empty
string key, allowing the auth decorator to handle the 401 correctly.

Fixes #3592

Disclaimer: AI assisted—humans supervised.
2026-02-21 16:54:14 -06:00
Ozzie Isaacs 1b4f0d0967 Merge remote-tracking branch 'add-to-shelves-dropdown-menu/add-to-shelves-dropdown-menu' 2026-02-21 09:43:50 +01:00
Ozzie Isaacs 181117f916 Merge remote-tracking branch 'Kobo_sync/fix/kobo-library-sync-url-rewrite' 2026-02-21 09:42:09 +01:00
Ozzie Isaacs 32741be6c8 Merge remote-tracking branch 'kobo_sync/pr/fix-kobo-progress' 2026-02-21 09:31:56 +01:00
Ozzie Isaacs f64fe5d835 Merge remote-tracking branch 'Exclamationmark/chore_fix_error_messages' 2026-02-21 09:29:02 +01:00
Ozzie Isaacs 0db93a6036 Merge remote-tracking branch 'sort/master' 2026-02-21 09:27:04 +01:00
leahjessie a4bf028537 Fix Kobo popup when ProgressPercent is 0 by using is-not-None check instead of truthiness 2026-02-20 15:34:25 -08:00
AsukaVuuyn 47625a616d fix: support native UTF-8 downloaded filenames for browsers 2026-02-20 23:07:06 +08:00
Ozzie Isaacs 87635a5ace Merge branch 'Develop'
Update Requirements
Update Teststatus
2026-02-20 11:50:43 +01:00
Noé Sierra-Velasquez a9713bd497 Fix Kobo sync failure behind reverse proxy: rewrite library_sync URL in init response
The HandleInitRequest() function rewrites image URLs to point to the local
Calibre-Web instance, but library_sync was left pointing to storeapi.kobo.com.
This caused Kobo e-readers to call Kobo's servers for sync instead of the local
server, resulting in no books being synced when running behind a reverse proxy.
2026-02-19 20:34:50 +01:00
Ozzie Isaacs 8cef72b847 Updated requirements 2026-02-18 19:24:23 +01:00
Ozzie Isaacs fbed304df8 Better Fix for flask limiter 4.0 2026-02-18 19:11:38 +01:00
Ozzie Isaacs e3bf369ad6 Fix for flask limiter 4.0
Fix reseting flask_limiter keys
2026-02-18 18:59:29 +01:00
leahjessie d229f71151 Fix Kobo "Return to last page read" popup caused by float/int mismatch 2026-02-17 16:34:46 -08:00
Courville Software 3aef161cb8 fix title sort with "l'" in french
regex should not impose a space after "l'" otherwise detection for "L'arbre" fails.
2026-02-14 17:06:44 +01:00
Ozzie Isaacs cabcace3f0 Update translation
Fixes for flask_limiter version >4
2026-02-14 11:30:50 +01:00
Ozzie Isaacs 424e493946 Lower version of flask_limiter again 2026-02-14 11:30:15 +01:00
Ozzie Isaacs 848302f69e Merge branch 'master' into Develop 2026-02-14 10:21:05 +01:00
Ozzie Isaacs 8bdd95fc45 Fixes for flask_limiter > 4 2026-02-14 10:08:32 +01:00
Ozzie Isaacs 9c7a834cec Fix italian translation error 2026-02-13 19:18:00 +01:00
Ozzie Isaacs 51a2f36966 Revert flask_limiter version 2026-02-13 19:16:18 +01:00
Ozzie Isaacs b64645e5f5 Cover path is now selected based on correct setting and not if split_path is present or not (fix for #3527)
Version bump
Updated requirements
2026-02-08 12:12:34 +01:00
Ozzie Fernandez Isaacs bed8957eae Update pyproject.toml 2026-02-07 09:22:39 +01:00
Ozzie Fernandez Isaacs 0ce8c19e88 Update pyproject.toml 2026-02-06 21:59:49 +01:00
lb803 315584690d remove exclamation marks 2026-02-05 21:40:45 +00:00
Webysther Sperandio a91724edb0 💄 style(css): include remove menu in dropdown scroll styling 2026-01-28 21:09:47 +01:00
Webysther Sperandio 5ad6b7fa64 💄 style(ui): limit add-to-shelves dropdown height
- 【style】 constrain add-to-shelves dropdown height and enable scroll
  - 【style】 prevent overly tall menus from overflowing the page
2026-01-26 19:39:36 +01:00
Webysther Sperandio c1be944a48 💄 style(ui): limit add-to-shelves dropdown height
- 【style】 constrain add-to-shelves dropdown height and enable scroll
  - 【style】 prevent overly tall menus from overflowing the page
2026-01-26 19:36:47 +01:00
98 changed files with 7164 additions and 6886 deletions
+3 -2
View File
@@ -25,7 +25,7 @@ import sys
import os
import mimetypes
from flask import Flask, request
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
from .MyLoginManager import MyLoginManager
from flask_principal import Principal
@@ -111,7 +111,8 @@ web_server = WebServer()
updater_thread = Updater()
if limiter_present:
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=False)
limiter = Limiter(key_func=True, headers_enabled=True, in_memory_fallback_enabled=True, default_limits=[],
swallow_errors=True)
else:
limiter = None
+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
+7 -2
View File
@@ -35,8 +35,13 @@ def clean_string(unsafe_text, book_id=0):
try:
if bleach:
allowed_tags = list(ALLOWED_TAGS)
allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6"])
safe_text = clean_html(unsafe_text, tags=set(allowed_tags))
allowed_tags.extend(["p", "span", "div", "pre", "br", "h1", "h2", "h3", "h4", "h5", "h6", "img"])
allowed_attributes = {
"*": ["class", "style"],
"a": ["href", "title", "rel"],
"img": ["src", "alt", "title", "width", "height"],
}
safe_text = clean_html(unsafe_text, tags=set(allowed_tags), attributes=allowed_attributes)
else:
safe_text = clean_html(unsafe_text)
except ParserError as e:
+7 -4
View File
@@ -84,8 +84,8 @@ class _Settings(_Base):
config_authors_max = Column(Integer, default=0)
config_read_column = Column(Integer, default=0)
config_title_regex = Column(String,
default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine'
r'|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
default=r"^(A|The|An|Der|Die|Das|Den|Ein|Eine"
r"|Einen|Dem|Des|Einem|Eines|Le|La|Les|L'|Un|Une)(\s+|(?<='))")
config_theme = Column(Integer, default=0)
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
@@ -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
@@ -403,7 +405,7 @@ class ConfigSQL(object):
self.save()
def get_book_path(self):
return self.config_calibre_split_dir if self.config_calibre_split_dir else self.config_calibre_dir
return self.config_calibre_split_dir if self.config_calibre_split else self.config_calibre_dir
def store_calibre_uuid(self, calibre_db, Library_table):
from . import app
@@ -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
+1 -1
View File
@@ -175,7 +175,7 @@ BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, d
'series_id, languages, publisher, pubdate, identifiers')
# python build process likes to have x.y.zbw -> b for beta and w a counting number
STABLE_VERSION = '0.6.26'
STABLE_VERSION = '0.6.27b'
NIGHTLY_VERSION = dict()
NIGHTLY_VERSION[0] = '$Format:%H$'
+26 -10
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
@@ -746,7 +753,8 @@ class CalibreDB:
.filter(self.common_filters(allow_show_archived)).first())
def get_book_by_uuid(self, book_uuid):
return self.session.query(Books).filter(Books.uuid == book_uuid).first()
return self.session.query(Books).filter(Books.uuid == book_uuid). \
filter(self.common_filters()).first()
def get_book_format(self, book_id, file_format):
return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first()
@@ -944,16 +952,13 @@ class CalibreDB:
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
query = query or ''
self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
entries = self.session.query(database).filter(tag_filter). \
filter(func.lower(database.name).ilike("%" + query + "%")).all()
# json_dumps = json.dumps([dict(name=escape(r.name.replace(*replace))) for r in entries])
json_dumps = json.dumps([dict(name=r.name.replace(*replace)) for r in entries])
return json_dumps
def check_exists_book(self, authr, title):
self.create_functions()
# self.session.connection().connection.connection.create_function("lower", 1, lcase)
q = list()
author_terms = re.split(r'\s*&\s*', authr)
for author_term in author_terms:
@@ -1121,7 +1126,7 @@ class CalibreDB:
.filter(self.common_filters())
.count())
if no_lang_count:
tags.append([Category(_("None"), "none"), no_lang_count])
tags.append([Category(_("None"), "None", "none"), no_lang_count])
return sorted(tags, key=lambda x: x[0].name.lower(), reverse=reverse_order)
else:
if not languages:
@@ -1175,14 +1180,25 @@ def lcase(s):
return s.lower()
def title_sort(title, config):
# calibre sort stuff
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
match = title_pat.search(title)
if match:
prep = match.group(1)
title = title[len(prep):] + ', ' + prep
return strip_whitespaces(title)
class Category:
name = None
sort = None
id = None
count = None
rating = None
def __init__(self, name, cat_id, rating=None):
self.name = name
self.sort = name
self.id = cat_id
self.rating = rating
self.count = 1
+2 -2
View File
@@ -1611,7 +1611,7 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
if db_type == 'author':
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
elif db_type == 'series':
new_element = db_object(add_element, add_element)
new_element = db_object(add_element, db.title_sort(add_element, config))
elif db_type == 'custom':
new_element = db_object(value=add_element)
elif db_type == 'publisher':
@@ -1642,7 +1642,7 @@ def create_objects_for_addition(db_element, add_element, db_type):
elif db_type == 'series':
if db_element.name != add_element:
db_element.name = add_element
db_element.sort = add_element
db_element.sort = db.title_sort(add_element, config)
elif db_type == 'author':
if db_element.name != add_element:
db_element.name = add_element
+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):
+35 -3
View File
@@ -18,15 +18,22 @@
import traceback
from flask import render_template
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:
from werkzeug.exceptions import UnprocessableEntity as FailedDependency
from . import config, app, logger, services
from .render_template import render_title_template
from .web import render_login
from .usermanagement import auth
from cps.string_helper import strip_whitespaces
log = logger.create()
@@ -57,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 '
@@ -64,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
@@ -85,3 +99,21 @@ def init_errorhandler():
log.debug('LDAP server not accessible while trying to login to opds feed')
return error_http(FailedDependency())
@app.errorhandler(RateLimitExceeded)
def handle_rate_limit(__):
log.error("Rate limit exceeded {}".format(request.endpoint))
if "register" in request.endpoint:
flash(_(u"Please wait one minute to register next user"), category="error")
return render_title_template('register.html', config=config, title=_("Register"), page="register")
elif "login" in request.endpoint:
form = request.form.to_dict()
username = strip_whitespaces(form.get('username', "")).lower().replace("\n", "").replace("\r", "")
flash(_("Please wait one minute before next login"), category="error")
return render_login(username, form.get("password", ""))
elif "opds" in request.endpoint:
return auth.auth_error_callback(429)
else:
return make_response('', 429)
+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')
+18 -2
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:
@@ -1091,8 +1102,13 @@ def get_download_link(book_id, book_format, client):
file_name = book.title
if len(book.authors) > 0:
file_name = file_name + ' - ' + book.authors[0].name
original_name = file_name
file_name = get_valid_filename(file_name, replace_whitespace=False, force_unidecode=True)
quoted_file_name = file_name if client == "kindle" else quote(file_name)
if client == "kindle":
quoted_file_name = file_name
else:
native_name = get_valid_filename(original_name, replace_whitespace=False, force_unidecode=False)
quoted_file_name = quote(native_name)
headers = Headers()
headers["Content-Type"] = mimetypes.types_map.get('.' + book_format, "application/octet-stream")
headers["Content-Disposition"] = ('attachment; filename="{}.{}"; filename*=UTF-8\'\'{}.{}').format(
+7
View File
@@ -30,6 +30,7 @@ from uuid import uuid4
from flask import Blueprint, request, url_for, g
from flask_babel import format_date
from .cw_login import current_user
from .clean_html import clean_string as html_clean_string
from . import constants, logger
@@ -181,3 +182,9 @@ def contains_music(book_formats):
if format.format.lower() in g.constants.EXTENSIONS_AUDIO:
result = True
return result
@jinjia.app_template_filter('clean_string')
def clean_string(unsafe_text):
return html_clean_string(unsafe_text)
+35 -12
View File
@@ -25,6 +25,7 @@ import zipfile
from time import gmtime, strftime
import json
from urllib.parse import unquote
import requests
from flask import (
Blueprint,
@@ -41,10 +42,9 @@ from werkzeug.datastructures import Headers
from sqlalchemy import func
from sqlalchemy.sql.expression import and_, or_
from sqlalchemy.exc import StatementError
import requests
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
from . import isoLanguages
from . import isoLanguages, limiter
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
@@ -140,7 +140,6 @@ def convert_to_kobo_timestamp_string(timestamp):
@kobo.route("/v1/library/sync")
@requires_kobo_auth
# @download_required
def HandleSyncRequest():
if not current_user.role_download():
log.info("Users need download permissions for syncing library to Kobo reader")
@@ -368,7 +367,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,6 +502,7 @@ def get_metadata(book):
return metadata
@csrf.exempt
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
@requires_kobo_auth
@@ -811,6 +811,8 @@ def HandleStateRequest(book_uuid):
ub.session.merge(kobo_reading_state)
ub.session_commit()
update_results_response["LastModified"] = convert_to_kobo_timestamp_string(kobo_reading_state.last_modified)
update_results_response["PriorityTimestamp"] = convert_to_kobo_timestamp_string(kobo_reading_state.priority_timestamp)
return jsonify({
"RequestResult": "Success",
"UpdateResults": [update_results_response],
@@ -887,14 +889,21 @@ def get_statistics_response(statistics):
return resp
def _clean_progress(value):
"""Return progress as int if it's a whole number, preserving Kobo device expectations."""
if value is not None and value == int(value):
return int(value)
return value
def get_current_bookmark_response(current_bookmark):
resp = {
"LastModified": convert_to_kobo_timestamp_string(current_bookmark.last_modified),
}
if current_bookmark.progress_percent:
resp["ProgressPercent"] = current_bookmark.progress_percent
if current_bookmark.content_source_progress_percent:
resp["ContentSourceProgressPercent"] = current_bookmark.content_source_progress_percent
if current_bookmark.progress_percent is not None:
resp["ProgressPercent"] = _clean_progress(current_bookmark.progress_percent)
if current_bookmark.content_source_progress_percent is not None:
resp["ContentSourceProgressPercent"] = _clean_progress(current_bookmark.content_source_progress_percent)
if current_bookmark.location_value:
resp["Location"] = {
"Value": current_bookmark.location_value,
@@ -962,6 +971,7 @@ def HandleBookDeletionRequest(book_uuid):
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET", "POST"])
@kobo.route("/v1/library/<dummy>/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 +986,9 @@ def HandleUnimplementedRequest(dummy=None):
@kobo.route("/v1/analytics/<dummy>", 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 +996,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 +1006,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 +1033,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()
@@ -1047,6 +1063,8 @@ def make_calibre_web_auth_response():
@kobo.route("/v1/auth/device", methods=["POST"])
@requires_kobo_auth
def HandleAuthRequest():
log.error(limiter.current_limit)
log.error(limiter.current_limit)
log.debug('Kobo Auth request')
if config.config_kobo_proxy:
try:
@@ -1088,7 +1106,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,15 +1114,17 @@ 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}",
isGreyscale='false'))
kobo_resources["library_sync"] = calibre_web_url + url_for("kobo.HandleSyncRequest",
auth_token=kobo_auth.get_auth_token())
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,12 +1132,15 @@ 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}",
isGreyscale='false',
_external=True))
kobo_resources["library_sync"] = url_for("kobo.HandleSyncRequest",
auth_token=kobo_auth.get_auth_token(),
_external=True)
response = make_response(jsonify({"Resources": kobo_resources}))
response.headers["x-kobo-apitoken"] = "e30="
+5 -9
View File
@@ -67,7 +67,6 @@ from functools import wraps
from flask import g, Blueprint, abort, request
from .cw_login import login_user, current_user
from flask_babel import gettext as _
from flask_limiter import RateLimitExceeded
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
from .render_template import render_title_template
@@ -82,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:
@@ -124,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()
@@ -154,13 +157,6 @@ def requires_kobo_auth(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)
@@ -169,7 +165,7 @@ def requires_kobo_auth(f):
)
if user is not None:
login_user(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 f(*args, **kwargs)
log.debug("Received Kobo request without a recognizable auth token.")
return abort(401)
+3 -3
View File
@@ -24,7 +24,7 @@ from flask import request
def request_username():
return request.authorization.username
return request.authorization.username if request.authorization else ""
def main():
@@ -66,8 +66,8 @@ def main():
app.register_blueprint(tasks)
app.register_blueprint(web)
app.register_blueprint(basic)
app.register_blueprint(opds)
limiter.limit("3/minute", key_func=request_username)(opds)
app.register_blueprint(opds)
app.register_blueprint(jinjia)
app.register_blueprint(about)
app.register_blueprint(shelf)
@@ -77,9 +77,9 @@ 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)
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
if oauth_available:
app.register_blueprint(oauth)
success = web_server.start()
+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
View File
@@ -258,7 +258,6 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
calibre_db.create_functions()
# calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
.outerjoin(db.Series)\
+1 -1
View File
@@ -135,5 +135,5 @@ def metadata_search():
if active.get(c.__id__, True)
}
for future in concurrent.futures.as_completed(meta):
data.extend([asdict(x) for x in future.result() if x])
data.extend([asdict(x) for x in (future.result() or []) if x])
return make_response(jsonify(data))
+6 -4
View File
@@ -41,7 +41,7 @@ except ImportError:
VERSION = 'Tornado ' + _version
_GEVENT = False
from . import logger
from . import logger, constants
log = logger.create()
@@ -216,9 +216,10 @@ class WebServer(object):
try:
sock, output = self._make_gevent_listener()
log.info('Starting Gevent server on %s', output)
# Also print to stdout so interactive terminals show a clear success message
try:
print(f"Calibre-Web: server started on {output}")
# Also print to stdout so interactive terminals show a clear success message
if constants.APP_MODE not in ['development', 'test']:
print(f"Calibre-Web: server started on {output}")
except Exception:
print(f"Calibre-Web: error {output}")
pass
@@ -274,7 +275,8 @@ class WebServer(object):
log.info('Starting Tornado server on %s', output)
# Also print to stdout so interactive terminals show a clear success message
try:
print(f"Calibre-Web: server started on {output}")
if constants.APP_MODE not in ['development', 'test']:
print(f"Calibre-Web: server started on {output}")
except Exception:
print(f"Calibre-Web: error {output}")
pass
+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()
+4
View File
@@ -1,3 +1,7 @@
#add-to-shelves.dropdown-menu, #remove-from-shelves.dropdown-menu {
max-height: 300px;
overflow-y: auto;
}
.tooltip.bottom .tooltip-inner {
font-size: 13px;
+2 -2
View File
@@ -103,11 +103,11 @@ $(function () {
});
}
else {
$("#meta-info").html("<p class=\"text-danger\">" + msg.no_result + "!</p>" + $("#meta-info")[0].innerHTML)
$("#meta-info").html("<p class=\"text-danger\">" + msg.no_result + "</p>" + $("#meta-info")[0].innerHTML)
}
},
error: function error() {
$("#meta-info").html("<p class=\"text-danger\">" + msg.search_error + "!</p>" + $("#meta-info")[0].innerHTML);
$("#meta-info").html("<p class=\"text-danger\">" + msg.search_error + "</p>" + $("#meta-info")[0].innerHTML);
},
});
}
+2 -2
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 %}
@@ -74,7 +74,7 @@
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %}
<div>
<h2 id="decription">{{ _('Description:') }}</h2>
{{ entry.comments[0].text|safe }}
{{ entry.comments[0].text|clean_string|safe }}
</div>
{% endif %}
</div>
+3 -3
View File
@@ -232,7 +232,7 @@
{% elif c.datatype == 'datetime' %}
{{ column.value|formatdate }}
{% elif c.datatype == 'comments' %}
{{ column.value|safe }}
{{ column.value|clean_string|safe }}
{% elif c.datatype == 'series' %}
{{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }}
{% elif c.datatype == 'text' %}
@@ -284,7 +284,7 @@
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0 %}
<div class="comments">
<h3 id="decription">{{ _('Description:') }}</h3>
{{ entry.comments[0].text|safe }}
{{ entry.comments[0].text|clean_string|safe }}
</div>
{% endif %}
@@ -294,7 +294,7 @@
{% if current_user.is_authenticated %}
{% if current_user.shelf.all() or g.shelves_access %}
<div id="shelf-actions" class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group" aria-label="Add to shelves">
<div class="btn-group dropup" role="group" aria-label="Add to shelves">
<button id="add-to-shelf" type="button"
class="btn btn-primary btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
+1 -1
View File
@@ -94,7 +94,7 @@
{% elif c.datatype == 'datetime' %}
{{ column.value|formatdate }}
{% elif c.datatype == 'comments' %}
{{ column.value|safe }}
{{ column.value|clean_string|safe }}
{% elif c.datatype == 'series' %}
{{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }}
{% elif c.datatype == 'text' %}
+2 -2
View File
@@ -134,7 +134,7 @@
{% elif c.datatype == 'datetime' %}
{{ column.value|formatdate }}
{% elif c.datatype == 'comments' %}
{{column.value|safe}}
{{column.value|clean_string|safe}}
{% elif c.datatype == 'series' %}
{{ '%s [%s]' % (column.value, column.extra|formatfloat(2)) }}
{% elif c.datatype == 'text' %}
@@ -175,7 +175,7 @@
{% if entry.comments|length > 0 and entry.comments[0].text|length > 0%}
<div class="comments">
<h3 id="decription">{{_('Description:')}}</h3>
{{entry.comments[0].text|safe}}
{{entry.comments[0].text|clean_string|safe}}
</div>
{% endif %}
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
+1 -1
View File
@@ -537,7 +537,7 @@ class RemoteAuthToken(Base):
def __init__(self):
super().__init__()
self.auth_token = (hexlify(os.urandom(4))).decode('utf-8')
self.auth_token = (hexlify(os.urandom(16))).decode('utf-8')
self.expiration = datetime.now() + timedelta(minutes=10) # 10 min from now
def __repr__(self):
+4 -4
View File
@@ -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()
# 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
+9 -27
View File
@@ -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
@@ -303,7 +301,6 @@ def get_matching_tags():
tag_dict = {'tags': []}
q = calibre_db.session.query(db.Books).filter(calibre_db.common_filters(True))
calibre_db.create_functions()
# calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
author_input = request.args.get('authors') or ''
title_input = request.args.get('title') or ''
include_tag_inputs = request.args.getlist('include_tag') or ''
@@ -714,7 +711,7 @@ def render_language_books(page, name, order):
lang_name = _("None")
except KeyError:
abort(404)
if name == "none":
if name.lower() == "none":
entries, random, pagination = calibre_db.fill_indexpage(page, 0,
db.Books,
db.Languages.lang_code == None,
@@ -845,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":
@@ -1028,7 +1026,7 @@ def series_list():
.count())
if no_series_count:
entries.append([db.Category(_("None"), "-1"), no_series_count])
entries = sorted(entries, key=lambda x: x[0].name.lower(), reverse=not order_no)
entries = sorted(entries, key=lambda x: (x[0].sort or x[0].name).lower(), reverse=not order_no)
return render_title_template('list.html',
entries=entries,
folder='web.books_list',
@@ -1197,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"
@@ -1285,15 +1285,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 +1349,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 +1383,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:
+232 -230
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,8 +1,8 @@
# GDrive Integration
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
@@ -21,19 +21,19 @@ python-Levenshtein>=0.12.0,<0.28.0
# ldap login
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 extraction
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
+21 -22
View File
@@ -7,10 +7,10 @@ name = "calibreweb"
description = "Web app for browsing, reading and downloading eBooks stored in a Calibre database."
authors = [{name = "@OzzieIsaacs", email = "Ozzie.Fernandez.Isaacs@googlemail.com"}]
maintainers = [{name = "@OzzieIsaacs"}]
license = {text = "GPLv3+"}
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
@@ -18,6 +18,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
]
keywords = [
@@ -33,27 +34,27 @@ dependencies = [
"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,<25.0.0;python_version>='3.12'",
"PyPDF>=6.1.3,<6.5.0",
"pycountry>=20.0.0,<27.0.0;python_version>='3.12'",
"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",
"unidecode>=0.04.19,<1.4.0",
"lxml>=4.9.1,<5.4.0",
"Wand>=0.4.4,<0.8.0",
"unidecode>=0.04.19,<1.5.0",
"lxml>=4.9.1,<6.2.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,<48.0.0",
"certifi>=2024.7.4,<2026.1.5",
]
dynamic = ["version"]
@@ -71,9 +72,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",
@@ -92,19 +93,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",
@@ -119,12 +120,10 @@ kobo = [
]
[project.scripts]
cps = "calibreweb:main"
cps = "calibreweb.__main__:main"
[tool.setuptools]
include-package-data = true
license-files = ["LICENSE"]
[tool.setuptools.dynamic]
version = {attr = "calibreweb.cps.constants.STABLE_VERSION"}
+10 -10
View File
@@ -4,24 +4,24 @@ Flask-Babel>=3.0.0,<4.1.0
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,<25.0.0;python_version>='3.12'
PyPDF>=6.1.3,<6.5.0
pycountry>=20.0.0,<27.0.0;python_version>='3.12'
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
unidecode>=0.04.19,<1.4.0
lxml>=4.9.1,<5.4.0
Wand>=0.4.4,<0.8.0
unidecode>=0.04.19,<1.5.0
lxml>=4.9.1,<6.2.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,<48.0.0
certifi>=2024.7.4,<2026.1.5
+219 -115
View File
@@ -37,20 +37,20 @@
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3" style="margin-top:50px;">
<p class='text-justify attribute'><strong>Start Time: </strong>2026-01-24 20:50:12</p>
<p class='text-justify attribute'><strong>Start Time: </strong>2026-05-16 12:48:02</p>
</div>
</div>
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3">
<p class='text-justify attribute'><strong>Stop Time: </strong>2026-01-25 04:15:44</p>
<p class='text-justify attribute'><strong>Stop Time: </strong>2026-05-16 20:11:29</p>
</div>
</div>
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3">
<p class='text-justify attribute'><strong>Duration: </strong>6h 13 min</p>
<p class='text-justify attribute'><strong>Duration: </strong>6h 9 min</p>
</div>
</div>
</div>
@@ -2070,7 +2070,7 @@
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_books_metadata.py&#34;, line 87, in test_load_metadata
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_books_metadata.py&#34;, line 90, in test_load_metadata
if results[cont][&#39;source&#39;] == &#39;https://comicvine.gamespot.com/&#39;:
~~~~~~~^^^^^^
IndexError: list index out of range</pre>
@@ -2434,11 +2434,11 @@ AssertionError: 30 != 20</pre>
<tr id="su" class="passClass">
<tr id="su" class="failClass">
<td>TestEmbedMetadata</td>
<td class="text-center">6</td>
<td class="text-center">6</td>
<td class="text-center">0</td>
<td class="text-center">4</td>
<td class="text-center">2</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">
@@ -2466,11 +2466,31 @@ AssertionError: 30 != 20</pre>
<tr id='pt23.3' class='hiddenRow bg-success'>
<tr id="ft23.3" class="none bg-danger">
<td>
<div class='testcase'>TestEmbedMetadata - test_download_check_metadata</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft23.3')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft23.3" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_ft23.3').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_embed_metadata.py&#34;, line 84, in test_download_check_metadata
self.assertEqual(20746, len(epub_content))
AssertionError: 20746 != 6972</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
</tr>
@@ -2484,11 +2504,31 @@ AssertionError: 30 != 20</pre>
<tr id='pt23.5' class='hiddenRow bg-success'>
<tr id="ft23.5" class="none bg-danger">
<td>
<div class='testcase'>TestEmbedMetadata - test_download_permissions_missing_file</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft23.5')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft23.5" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_ft23.5').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_embed_metadata.py&#34;, line 108, in test_download_permissions_missing_file
self.assertEqual(20746, len(epub_content))
AssertionError: 20746 != 6972</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
</tr>
@@ -3060,13 +3100,13 @@ AssertionError: 30 != 20</pre>
<tr id="su" class="passClass">
<tr id="su" class="skipClass">
<td>TestSecurity</td>
<td class="text-center">6</td>
<td class="text-center">6</td>
<td class="text-center">0</td>
<td class="text-center">5</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">1</td>
<td class="text-center">
<a onclick="showClassDetail('c33', 6)">Detail</a>
</td>
@@ -3119,22 +3159,22 @@ AssertionError: 30 != 20</pre>
<tr id='pt33.6' class='hiddenRow bg-success'>
<tr id='st33.6' class='none bg-warning'>
<td>
<div class='testcase'>TestSecurity - test_x_forwarded_host</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6' align='center'>SKIP</td>
</tr>
<tr id="su" class="passClass">
<tr id="su" class="errorClass">
<td>TestCalibreWebListOrders</td>
<td class="text-center">16</td>
<td class="text-center">16</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">12</td>
<td class="text-center">2</td>
<td class="text-center">2</td>
<td class="text-center">0</td>
<td class="text-center">
<a onclick="showClassDetail('c34', 16)">Detail</a>
@@ -3188,38 +3228,120 @@ AssertionError: 30 != 20</pre>
<tr id='pt34.6' class='hiddenRow bg-success'>
<tr id="ft34.6" class="none bg-danger">
<td>
<div class='testcase'>TestCalibreWebListOrders - test_language_click_none</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft34.6')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft34.6" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_ft34.6').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_list_orders.py&#34;, line 548, in test_language_click_none
self.assertEqual(int(element[&#39;count&#39;]), len(books[1]))
AssertionError: 5 != 0</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
</tr>
<tr id='pt34.7' class='hiddenRow bg-success'>
<tr id="et34.7" class="none bg-info">
<td>
<div class='testcase'>TestCalibreWebListOrders - test_order_authors_all_links</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_et34.7')">ERROR</a>
</div>
<!--css div popup start-->
<div id="div_et34.7" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_et34.7').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_list_orders.py&#34;, line 434, in test_order_authors_all_links
self.check_element_on_page((By.ID, &#34;asc&#34;)).click()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: &#39;bool&#39; object has no attribute &#39;click&#39;</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
</tr>
<tr id='pt34.8' class='hiddenRow bg-success'>
<tr id="ft34.8" class="none bg-danger">
<td>
<div class='testcase'>TestCalibreWebListOrders - test_order_series_all_links</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft34.8')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft34.8" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_ft34.8').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_list_orders.py&#34;, line 367, in test_order_series_all_links
self.assertTrue(list)
AssertionError: False is not true</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
</tr>
<tr id='pt34.9' class='hiddenRow bg-success'>
<tr id="et34.9" class="none bg-info">
<td>
<div class='testcase'>TestCalibreWebListOrders - test_publisher_click_none</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_et34.9')">ERROR</a>
</div>
<!--css div popup start-->
<div id="div_et34.9" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_et34.9').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_list_orders.py&#34;, line 557, in test_publisher_click_none
self.assertTrue(int(element[&#39;count&#39;]) &gt; 0)
^^^^^^^
UnboundLocalError: cannot access local variable &#39;element&#39; where it is not associated with a value</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
</tr>
@@ -4040,43 +4162,45 @@ AssertionError: 30 != 20</pre>
<tr id="su" class="passClass">
<td>TestPipInstall</td>
<td class="text-center">3</td>
<td class="text-center">3</td>
<tr id="su" class="errorClass">
<td>_ErrorHolder</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">
<a onclick="showClassDetail('c42', 3)">Detail</a>
<a onclick="showClassDetail('c42', 1)">Detail</a>
</td>
</tr>
<tr id='pt42.1' class='hiddenRow bg-success'>
<tr id="et42.1" class="none bg-info">
<td>
<div class='testcase'>TestPipInstall - test_command_start</div>
<div class='testcase'>setUpClass (test_pip_install)</div>
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
<tr id='pt42.2' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestPipInstall - test_foldername_database_location</div>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_et42.1')">ERROR</a>
</div>
<!--css div popup start-->
<div id="div_et42.1" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_et42.1').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_pip_install.py&#34;, line 42, in setUpClass
raise FileNotFoundError(&#34;Whl file not found for pip, aborting pip install test&#34;)
FileNotFoundError: Whl file not found for pip, aborting pip install test</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
<tr id='pt42.3' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestPipInstall - test_module_start</div>
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
@@ -4684,11 +4808,11 @@ AssertionError: 30 != 20</pre>
<tr id="su" class="failClass">
<tr id="su" class="skipClass">
<td>TestThumbnails</td>
<td class="text-center">8</td>
<td class="text-center">6</td>
<td class="text-center">1</td>
<td class="text-center">7</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">1</td>
<td class="text-center">
@@ -4761,31 +4885,11 @@ AssertionError: 30 != 20</pre>
<tr id="ft52.8" class="none bg-danger">
<tr id='pt52.8' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestThumbnails - test_sideloaded_book</div>
</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft52.8')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft52.8" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_ft52.8').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_thumbnails.py&#34;, line 317, in test_sideloaded_book
self.assertAlmostEqual(diff(BytesIO(list_cover), BytesIO(old_list_cover), delete_diff_file=True), 0.0,
AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182775 difference)</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
@@ -5991,11 +6095,11 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr id='total_row' class="text-center bg-grey">
<td>Total</td>
<td>538</td>
<td>528</td>
<td>2</td>
<td>1</td>
<td>7</td>
<td>536</td>
<td>519</td>
<td>5</td>
<td>4</td>
<td>8</td>
<td>&nbsp;</td>
</tr>
</table>
@@ -6023,7 +6127,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>Platform</th>
<td>Linux 6.8.0-90-generic #91-Ubuntu SMP PREEMPT_DYNAMIC Tue Nov 18 14:14:30 UTC 2025 x86_64 x86_64</td>
<td>Linux 6.17.0-29-generic #29~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon May 11 10:30:58 UTC 2 x86_64 x86_64</td>
<td>Basic</td>
</tr>
@@ -6041,19 +6145,19 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>babel</th>
<td>2.17.0</td>
<td>2.18.0</td>
<td>Basic</td>
</tr>
<tr>
<th>bleach</th>
<td>6.2.0</td>
<td>6.3.0</td>
<td>Basic</td>
</tr>
<tr>
<th>certifi</th>
<td>2025.8.3</td>
<td>2026.1.4</td>
<td>Basic</td>
</tr>
@@ -6065,13 +6169,13 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>cryptography</th>
<td>44.0.3</td>
<td>47.0.0</td>
<td>Basic</td>
</tr>
<tr>
<th>Flask</th>
<td>3.1.2</td>
<td>3.1.3</td>
<td>Basic</td>
</tr>
@@ -6083,13 +6187,13 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>Flask-HTTPAuth</th>
<td>4.8.0</td>
<td>4.8.1</td>
<td>Basic</td>
</tr>
<tr>
<th>Flask-Limiter</th>
<td>3.12</td>
<td>4.1.1</td>
<td>Basic</td>
</tr>
@@ -6107,7 +6211,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>greenlet</th>
<td>3.3.1</td>
<td>3.5.0</td>
<td>Basic</td>
</tr>
@@ -6119,7 +6223,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>lxml</th>
<td>5.3.2</td>
<td>6.1.0</td>
<td>Basic</td>
</tr>
@@ -6131,13 +6235,13 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>pycountry</th>
<td>24.6.1</td>
<td>26.2.16</td>
<td>Basic</td>
</tr>
<tr>
<th>pypdf</th>
<td>6.4.2</td>
<td>6.10.2</td>
<td>Basic</td>
</tr>
@@ -6149,13 +6253,13 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>pytz</th>
<td>2025.2</td>
<td>2026.2</td>
<td>Basic</td>
</tr>
<tr>
<th>regex</th>
<td>2024.11.6</td>
<td>2026.1.15</td>
<td>Basic</td>
</tr>
@@ -6167,43 +6271,43 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>SQLAlchemy</th>
<td>2.0.46</td>
<td>2.0.49</td>
<td>Basic</td>
</tr>
<tr>
<th>tornado</th>
<td>6.5.4</td>
<td>6.5.5</td>
<td>Basic</td>
</tr>
<tr>
<th>Unidecode</th>
<td>1.3.8</td>
<td>1.4.0</td>
<td>Basic</td>
</tr>
<tr>
<th>urllib3</th>
<td>2.6.3</td>
<td>2.7.0</td>
<td>Basic</td>
</tr>
<tr>
<th>Wand</th>
<td>0.6.13</td>
<td>0.7.0</td>
<td>Basic</td>
</tr>
<tr>
<th>Werkzeug</th>
<td>3.1.5</td>
<td>3.1.8</td>
<td>Basic</td>
</tr>
<tr>
<th>google-api-python-client</th>
<td>2.188.0</td>
<td>2.196.0</td>
<td>TestBackupMetadataGdrive</td>
</tr>
@@ -6233,7 +6337,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>google-api-python-client</th>
<td>2.188.0</td>
<td>2.196.0</td>
<td>TestCliGdrivedb</td>
</tr>
@@ -6263,7 +6367,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>google-api-python-client</th>
<td>2.188.0</td>
<td>2.196.0</td>
<td>TestEbookConvertCalibreGDrive</td>
</tr>
@@ -6293,7 +6397,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>google-api-python-client</th>
<td>2.188.0</td>
<td>2.196.0</td>
<td>TestEbookConvertGDriveKepubify</td>
</tr>
@@ -6335,7 +6439,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>google-api-python-client</th>
<td>2.188.0</td>
<td>2.196.0</td>
<td>TestEditAuthorsGdrive</td>
</tr>
@@ -6371,7 +6475,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>google-api-python-client</th>
<td>2.188.0</td>
<td>2.196.0</td>
<td>TestEditBooksOnGdrive</td>
</tr>
@@ -6413,7 +6517,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>google-api-python-client</th>
<td>2.188.0</td>
<td>2.196.0</td>
<td>TestEmbedMetadataGdrive</td>
</tr>
@@ -6443,7 +6547,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>google-api-python-client</th>
<td>2.188.0</td>
<td>2.196.0</td>
<td>TestSetupGdrive</td>
</tr>
@@ -6496,8 +6600,8 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
</tr>
<tr>
<th>Flask-SimpleLDAP</th>
<td>2.0.0</td>
<th>flask-simpleldap</th>
<td>2.1.0</td>
<td>TestLdapLogin</td>
</tr>
@@ -6509,7 +6613,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
<tr>
<th>python-ldap</th>
<td>3.4.5</td>
<td>3.4.6</td>
<td>TestLdapLogin</td>
</tr>
@@ -6545,7 +6649,7 @@ AssertionError: 0.01731210309182775 != 0.0 within 0.0001 delta (0.01731210309182
</div>
<script>
drawCircle(528, 2, 1, 7);
drawCircle(519, 5, 4, 8);
showCase(5);
</script>