Files
Calibre-Web-Automated/cps/updater.py
T
crocodilestick a719e14a6c SQLite stability and concurrency:
- Added connection timeouts everywhere:
SQLAlchemy engines: connect_args={'timeout': 30}
sqlite3 direct connects: timeout=30 across scripts

- Enabled WAL mode on local disks and made it conditional via an environment flag.
-Added small retry/backoff in read-only UI probes to tolerate transient “database is locked.”

Increased Network share support (NFS/SMB):

- Introduced NETWORK_SHARE_MODE to disable WAL and suppress permission-changing operations that can break on network filesystems.
- Suppressed recursive chown when NETWORK_SHARE_MODE is true:
Python:
scripts/auto_library.py: skip chown for /config and /calibre-library during app.db/new library setup
scripts/ingest_processor.py: skip chown of library in set_library_permissions
scripts/convert_library.py: skip chown of convert-library.log; skip library chown
scripts/kindle_epub_fixer.py: skip chown of epub-fixer.log
cps/updater.py: disable os.chown during updates when flag is enabled
Shell:
scripts/setup-cwa.sh: guard chown of /etc/s6-overlay
root/etc/s6-overlay/s6-rc.d/cwa-init/run: guard initial chown of /config and cps/cache; guard requiredDirs loop

Documentation and configuration:

- README: Added “Network shares and SQLite WAL mode” section; documented that NETWORK_SHARE_MODE disables WAL and chown behavior for safety on network shares.
- docker-compose.yml and docker-compose.yml.dev: Exposed NETWORK_SHARE_MODE (default false) in environment.
Fixed a minor YAML indentation issue during compose edits.

Impacts on the Project:

- Fewer “database is locked” errors due to timeouts, gentle retries, and WAL on local disks.
- Safer behavior on NFS/SMB: WAL disabled and chown suppressed when NETWORK_SHARE_MODE=true.
- Clearer guidance for users via README and compose templates.

[bug] chown failed
Fixes #175
Calibre-Web-automated [bug]
Fixes #530
2025-08-18 12:35:12 +02:00

634 lines
27 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Calibre-Web Automated fork of Calibre-Web
# Copyright (C) 2018-2025 Calibre-Web contributors
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
# SPDX-License-Identifier: GPL-3.0-or-later
# See CONTRIBUTORS for full list of authors.
import sys
import os
import datetime
import json
import shutil
import threading
import time
import zipfile
from io import BytesIO
import requests
from flask_babel import format_datetime
from flask_babel import gettext as _
from . import constants, logger # config, web_server
from .file_helper import get_temp_dir
log = logger.create()
_REPOSITORY_API_URL = 'https://api.github.com/repos/janeczku/calibre-web'
def is_sha1(sha1):
if len(sha1) != 40:
return False
try:
int(sha1, 16)
except ValueError:
return False
return True
class Updater(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
self.web_server = None
self.config = None
self.paused = False
self.can_run = threading.Event()
self.pause()
self.status = -1
self.updateIndex = None
def init_updater(self, config, web_server):
self.config = config
self.web_server = web_server
def get_current_version_info(self):
if self.config.config_updatechannel == constants.UPDATE_STABLE:
return self._stable_version_info()
return self._nightly_version_info()
def get_available_updates(self, request_method):
if self.config.config_updatechannel == constants.UPDATE_STABLE:
return self._stable_available_updates(request_method)
return self._nightly_available_updates(request_method)
def do_work(self):
try:
self.status = 1
log.debug('Download update file')
headers = {'Accept': 'application/vnd.github.v3+json'}
r = requests.get(self._get_request_path(), stream=True, headers=headers, timeout=(10, 600))
r.raise_for_status()
self.status = 2
log.debug('Opening zipfile')
z = zipfile.ZipFile(BytesIO(r.content))
self.status = 3
log.debug('Extracting zipfile')
tmp_dir = get_temp_dir()
z.extractall(tmp_dir)
folder_name = os.path.join(tmp_dir, z.namelist()[0])[:-1]
if not os.path.isdir(folder_name):
self.status = 11
log.info('Extracted contents of zipfile not found in temp folder')
self.pause()
return False
self.status = 4
log.debug('Replacing files')
if self.update_source(folder_name, constants.BASE_DIR):
self.status = 6
log.debug('Preparing restart of server')
time.sleep(2)
self.web_server.stop(True)
self.status = 7
time.sleep(2)
return True
else:
self.status = 13
except requests.exceptions.HTTPError as ex:
log.error('HTTP Error %s', ex)
self.status = 8
except requests.exceptions.ConnectionError:
log.error('Connection error')
self.status = 9
except requests.exceptions.Timeout:
log.error('Timeout while establishing connection')
self.status = 10
except (requests.exceptions.RequestException, zipfile.BadZipFile):
self.status = 11
log.error('General error')
except (IOError, OSError) as ex:
self.status = 12
log.error('Possible Reason for error: update file could not be saved in temp dir')
log.error_or_exception(ex)
self.pause()
return False
def run(self):
while True:
self.can_run.wait()
if self.status > -1:
if self.do_work():
break # stop loop and end thread for restart
else:
break
def pause(self):
self.can_run.clear()
# should just resume the thread
def resume(self):
self.can_run.set()
def stop(self):
self.status = -2
self.can_run.set()
def get_update_status(self):
return self.status
@classmethod
def file_to_list(cls, filelist):
return [x.strip() for x in open(filelist, 'r') if not x.startswith('#EXT')]
@classmethod
def one_minus_two(cls, one, two):
return [x for x in one if x not in set(two)]
@classmethod
def reduce_dirs(cls, delete_files, new_list):
new_delete = []
for filename in delete_files:
parts = filename.split(os.sep)
sub = ''
for part in parts:
sub = os.path.join(sub, part)
if sub == '':
sub = os.sep
count = 0
for song in new_list:
if song.startswith(sub):
count += 1
break
if count == 0:
if sub != '\\':
new_delete.append(sub)
break
return list(set(new_delete))
@classmethod
def reduce_files(cls, remove_items, exclude_items):
rf = []
for item in remove_items:
if not item.startswith(exclude_items):
rf.append(item)
return rf
@classmethod
def check_permissions(cls, root_src_dir, root_dst_dir, log_function):
access = True
remove_path = len(root_src_dir) + 1
for src_dir, __, files in os.walk(root_src_dir):
root_dir = os.path.join(root_dst_dir, src_dir[remove_path:])
# Skip non-existing folders on check
if not os.path.isdir(root_dir):
continue
if not os.access(root_dir, os.R_OK | os.W_OK):
log_function("Missing permissions for {}".format(root_dir))
access = False
for file_ in files:
curr_file = os.path.join(root_dir, file_)
# Skip non-existing files on check
if not os.path.isfile(curr_file): # or curr_file.startswith('.'):
continue
if not os.access(curr_file, os.R_OK | os.W_OK):
log_function("Missing permissions for {}".format(curr_file))
access = False
return access
@classmethod
def move_all_files(cls, root_src_dir, root_dst_dir):
permission = None
new_permissions = os.stat(root_dst_dir)
log.debug('Performing Update on OS-System: %s', sys.platform)
change_permissions = not (sys.platform == "win32" or sys.platform == "darwin")
# Skip permission changes entirely in network share mode
try:
nsm = os.getenv("NETWORK_SHARE_MODE", "false").strip().lower() in ("1", "true", "yes", "on")
if nsm:
change_permissions = False
log.debug('NETWORK_SHARE_MODE=true detected; skipping chown operations during update')
except Exception:
pass
for src_dir, __, files in os.walk(root_src_dir):
dst_dir = src_dir.replace(root_src_dir, root_dst_dir, 1)
if not os.path.exists(dst_dir):
try:
os.makedirs(dst_dir)
log.debug('Create directory: {}'.format(dst_dir))
except OSError as e:
log.error('Failed creating folder: {} with error {}'.format(dst_dir, e))
if change_permissions:
try:
os.chown(dst_dir, new_permissions.st_uid, new_permissions.st_gid)
except OSError as e:
old_permissions = os.stat(dst_dir)
log.error('Failed changing permissions of %s. Before: %s:%s After %s:%s error: %s',
dst_dir, old_permissions.st_uid, old_permissions.st_gid,
new_permissions.st_uid, new_permissions.st_gid, e)
for file_ in files:
src_file = os.path.join(src_dir, file_)
dst_file = os.path.join(dst_dir, file_)
if os.path.exists(dst_file):
if change_permissions:
permission = os.stat(dst_file)
try:
os.remove(dst_file)
log.debug('Remove file before copy: {}'.format(dst_file))
except OSError as e:
log.error('Failed removing file: {} with error {}'.format(dst_file, e))
else:
if change_permissions:
permission = new_permissions
try:
shutil.move(src_file, dst_dir)
log.debug('Move File %s to %s', src_file, dst_dir)
except OSError as ex:
log.error('Failed moving file from {} to {} with error {}'.format(src_file, dst_dir, ex))
if change_permissions:
try:
os.chown(dst_file, permission.st_uid, permission.st_gid)
except OSError as e:
old_permissions = os.stat(dst_file)
log.error('Failed changing permissions of %s. Before: %s:%s After %s:%s error: %s',
dst_file, old_permissions.st_uid, old_permissions.st_gid,
permission.st_uid, permission.st_gid, e)
return
def update_source(self, source, destination):
# destination files
old_list = list()
exclude = self._add_excluded_files(log.info)
additional_path = self.is_venv()
if additional_path:
exclude.append(additional_path)
exclude = tuple(exclude)
# check if we are in a package, rename cps.py to __init__.py and __main__.py
if constants.HOME_CONFIG:
shutil.copy(os.path.join(source, 'cps.py'), os.path.join(source, '__main__.py'))
shutil.move(os.path.join(source, 'cps.py'), os.path.join(source, '__init__.py'))
for root, dirs, files in os.walk(destination, topdown=True):
for name in files:
old_list.append(os.path.join(root, name).replace(destination, ''))
for name in dirs:
old_list.append(os.path.join(root, name).replace(destination, ''))
# source files
new_list = list()
for root, dirs, files in os.walk(source, topdown=True):
for name in files:
new_list.append(os.path.join(root, name).replace(source, ''))
for name in dirs:
new_list.append(os.path.join(root, name).replace(source, ''))
delete_files = self.one_minus_two(old_list, new_list)
rf = self.reduce_files(delete_files, exclude)
remove_items = self.reduce_dirs(rf, new_list)
if self.check_permissions(source, destination, log.debug):
self.move_all_files(source, destination)
for item in remove_items:
item_path = os.path.join(destination, item[1:])
if os.path.isdir(item_path):
log.debug("Delete dir %s", item_path)
shutil.rmtree(item_path, ignore_errors=True)
else:
try:
os.remove(item_path)
log.debug("Delete file %s", item_path)
except OSError:
log.debug("Could not remove: %s", item_path)
shutil.rmtree(source, ignore_errors=True)
return True
else:
log.debug("Permissions missing for update")
return False
@staticmethod
def is_venv():
if (hasattr(sys, 'real_prefix')) or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix):
return os.sep + os.path.relpath(sys.prefix, constants.BASE_DIR)
else:
return False
@classmethod
def _nightly_version_info(cls):
if is_sha1(constants.NIGHTLY_VERSION[0]) and len(constants.NIGHTLY_VERSION[1]) > 0:
log.debug("Nightly version: {}, {}".format(constants.NIGHTLY_VERSION[0], constants.NIGHTLY_VERSION[1]))
return {'version': constants.NIGHTLY_VERSION[0], 'datetime': constants.NIGHTLY_VERSION[1]}
return False
@classmethod
def _stable_version_info(cls):
log.debug("Stable version: {}".format(constants.STABLE_VERSION))
return {'version': constants.STABLE_VERSION }
@classmethod
def dry_run(cls):
cls._add_excluded_files(print)
cls.check_permissions(constants.BASE_DIR, constants.BASE_DIR, print)
print("\n*** Finished ***")
@staticmethod
def _populate_parent_commits(update_data, status, tz, parents):
try:
parent_commit = update_data['parents'][0]
# limit the maximum search depth
remaining_parents_cnt = 10
except (IndexError, KeyError):
remaining_parents_cnt = None
parent_commit = None
if remaining_parents_cnt is not None:
while True:
if remaining_parents_cnt == 0:
break
# check if we are more than one update behind if so, go up the tree
if parent_commit['sha'] != status['current_commit_hash']:
try:
headers = {'Accept': 'application/vnd.github.v3+json'}
r = requests.get(parent_commit['url'], headers=headers, timeout=10)
r.raise_for_status()
parent_data = r.json()
parent_commit_date = datetime.datetime.strptime(
parent_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz
parent_commit_date = format_datetime(
parent_commit_date, format='short')
parents.append([parent_commit_date,
parent_data['message'].replace('\r\n', '<p>').replace('\n', '<p>')])
parent_commit = parent_data['parents'][0]
remaining_parents_cnt -= 1
except Exception:
# it isn't crucial if we can't get information about the parent
break
else:
# parent is our current version
break
return parents
@staticmethod
def _load_nightly_data(repository_url, commit, status):
update_data = dict()
try:
headers = {'Accept': 'application/vnd.github.v3+json'}
r = requests.get(repository_url + '/git/commits/' + commit['object']['sha'],
headers=headers,
timeout=10)
r.raise_for_status()
update_data = r.json()
except requests.exceptions.HTTPError as e:
status['message'] = _('HTTP Error') + ' ' + str(e)
except requests.exceptions.ConnectionError:
status['message'] = _('Connection error')
except requests.exceptions.Timeout:
status['message'] = _('Timeout while establishing connection')
except (requests.exceptions.RequestException, ValueError):
status['message'] = _('General error')
return status, update_data
@staticmethod
def _add_excluded_files(log_function):
excluded_files = [
os.sep + 'app.db', os.sep + 'calibre-web.log1', os.sep + 'calibre-web.log2', os.sep + 'gdrive.db',
os.sep + 'vendor', os.sep + 'calibre-web.log', os.sep + '.git', os.sep + 'client_secrets.json',
os.sep + 'gdrive_credentials', os.sep + 'settings.yaml', os.sep + 'venv', os.sep + 'virtualenv',
os.sep + 'access.log', os.sep + 'access.log1', os.sep + 'access.log2', os.sep + '.key',
os.sep + '.calibre-web.log.swp', os.sep + '_sqlite3.so', os.sep + 'cps' + os.sep + '.HOMEDIR',
os.sep + 'gmail.json', os.sep + 'exclude.txt', os.sep + 'cps' + os.sep + 'cache'
]
try:
with open(os.path.join(constants.BASE_DIR, "exclude.txt"), "r") as f:
lines = f.readlines()
for line in lines:
processed_line = line.strip("\n\r ").strip("\"'").lstrip("\\/ ").\
replace("\\", os.sep).replace("/", os.sep)
if os.path.exists(os.path.join(constants.BASE_DIR, processed_line)):
excluded_files.append(os.sep + processed_line)
else:
log_function("File list for updater: {} not found".format(line))
except (PermissionError, FileNotFoundError):
log_function("Excluded file list for updater not found, or not accessible")
return excluded_files
def _nightly_available_updates(self, request_method):
tz = datetime.timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone)
if request_method == "GET":
repository_url = _REPOSITORY_API_URL
status, commit = self._load_remote_data(repository_url + '/git/refs/heads/master')
parents = []
if status['message'] != '':
return json.dumps(status)
if 'object' not in commit or 'url' not in commit['object']:
status['message'] = _('Unexpected data while reading update information')
return json.dumps(status)
try:
if commit['object']['sha'] == status['current_commit_hash']:
status.update({
'update': False,
'success': True,
'message': _('No update available. You already have the latest version installed')
})
return json.dumps(status)
except (TypeError, KeyError):
status['message'] = _('Unexpected data while reading update information')
return json.dumps(status)
# a new update is available
status['update'] = True
status, update_data = self._load_nightly_data(repository_url, commit, status)
if status['message'] != '':
return json.dumps(status)
# if 'committer' in update_data and 'message' in update_data:
try:
log.debug("A new update is available.")
status['success'] = True
status['message'] = _(
'A new update is available. Click on the button below to update to the latest version.')
new_commit_date = datetime.datetime.strptime(
update_data['committer']['date'], '%Y-%m-%dT%H:%M:%SZ') - tz
parents.append(
[
format_datetime(new_commit_date, format='short'),
update_data['message'],
update_data['sha']
]
)
# it only makes sense to analyze the parents if we know the current commit hash
if status['current_commit_hash'] != '':
parents = self._populate_parent_commits(update_data, status, tz, parents)
status['history'] = parents[::-1]
except (IndexError, KeyError):
status['success'] = False
status['message'] = _('Could not fetch update information')
log.error("Could not fetch update information")
return json.dumps(status)
return ''
def _stable_updater_set_status(self, i, newer, status, parents, commit):
if i == -1 and newer is False:
status.update({
'update': True,
'success': True,
'message': _(
'Click on the button below to update to the latest stable version.'),
'history': parents
})
self.updateFile = commit[0]['zipball_url']
elif i == -1 and newer is True:
status.update({
'update': True,
'success': True,
'message': _('A new update is available. Click on the button below to '
'update to version: %(version)s', version=commit[0]['tag_name']),
'history': parents
})
self.updateFile = commit[0]['zipball_url']
return status
def _stable_updater_parse_major_version(self, commit, i, parents, current_version, status):
if int(commit[i + 1]['tag_name'].split('.')[1]) == current_version[1]:
parents.append([commit[i]['tag_name'],
commit[i]['body'].replace('\r\n', '<p>').replace('\n', '<p>')])
status.update({
'update': True,
'success': True,
'message': _(u'A new update is available. Click on the button below to '
u'update to version: %(version)s', version=commit[i]['tag_name']),
'history': parents
})
self.updateFile = commit[i]['zipball_url']
else:
parents.append([commit[i + 1]['tag_name'],
commit[i + 1]['body'].replace('\r\n', '<p>').replace('\n', '<p>')])
status.update({
'update': True,
'success': True,
'message': _(u'A new update is available. Click on the button below to '
u'update to version: %(version)s', version=commit[i + 1]['tag_name']),
'history': parents
})
self.updateFile = commit[i + 1]['zipball_url']
return status, parents
def _stable_available_updates(self, request_method):
status = None
if request_method == "GET":
parents = []
# repository_url = 'https://api.github.com/repos/flatpak/flatpak/releases' # test URL
repository_url = _REPOSITORY_API_URL + '/releases?per_page=100'
status, commit = self._load_remote_data(repository_url)
if status['message'] != '':
return json.dumps(status)
if not commit:
status['success'] = True
status['message'] = _(u'No release information available')
return json.dumps(status)
version = status['current_commit_hash']
current_version = status['current_commit_hash'].split('.')
# we are already on newest version, no update available
if 'tag_name' not in commit[0]:
status['message'] = _(u'Unexpected data while reading update information')
log.error("Unexpected data while reading update information")
return json.dumps(status)
if commit[0]['tag_name'] == version:
status.update({
'update': False,
'success': True,
'message': _(u'No update available. You already have the latest version installed')
})
return json.dumps(status)
i = len(commit) - 1
newer = False
while i >= 0:
if 'tag_name' not in commit[i] or 'body' not in commit[i] or 'zipball_url' not in commit[i]:
status['message'] = _(u'Unexpected data while reading update information')
return json.dumps(status)
major_version_update = int(commit[i]['tag_name'].split('.')[0])
minor_version_update = int(commit[i]['tag_name'].split('.')[1])
patch_version_update = int(commit[i]['tag_name'].split('.')[2])
current_version[0] = int(current_version[0])
current_version[1] = int(current_version[1])
try:
current_version[2] = int(current_version[2])
except ValueError:
current_version[2] = int(current_version[2].replace("b", "").split(' ')[0])-1
# Check if major versions are identical search for newest non-equal commit and update to this one
if major_version_update == current_version[0]:
if (minor_version_update == current_version[1] and
patch_version_update > current_version[2]) or \
minor_version_update > current_version[1]:
parents.append([commit[i]['tag_name'], commit[i]['body'].replace('\r\n', '<p>')])
newer = True
i -= 1
continue
if major_version_update < current_version[0]:
i -= 1
continue
if major_version_update > current_version[0]:
# found update to last version before major update, unless current version is on last version
# before major update
if i == (len(commit) - 1):
i -= 1
status, parents = self._stable_updater_parse_major_version(commit,
i,
parents,
current_version,
status)
break
status = self._stable_updater_set_status(i, newer, status, parents, commit)
return json.dumps(status)
def _get_request_path(self):
if self.config.config_updatechannel == constants.UPDATE_STABLE:
return self.updateFile
return _REPOSITORY_API_URL + '/zipball/master'
def _load_remote_data(self, repository_url):
status = {
'update': False,
'success': False,
'message': '',
'current_commit_hash': ''
}
commit = None
version = self.get_current_version_info()
if version is False:
status['current_commit_hash'] = _(u'Unknown')
else:
status['current_commit_hash'] = version['version']
try:
headers = {'Accept': 'application/vnd.github.v3+json'}
r = requests.get(repository_url, headers=headers, timeout=10)
commit = r.json()
r.raise_for_status()
except requests.exceptions.HTTPError as e:
if commit:
if 'message' in commit:
status['message'] = _(u'HTTP Error') + ': ' + commit['message']
else:
status['message'] = _(u'HTTP Error') + ': ' + str(e)
except requests.exceptions.ConnectionError as e:
status['message'] = _(u'Connection error')
except requests.exceptions.Timeout:
status['message'] = _(u'Timeout while establishing connection')
except (requests.exceptions.RequestException, ValueError):
status['message'] = _(u'General error')
log.debug('Updater status: {}'.format(status['message'] or "OK"))
return status, commit