# -*- coding: utf-8 -*- # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # Copyright (C) 2018-2019 OzzieIsaacs # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . from __future__ import division, print_function, unicode_literals import sys import os import datetime import json import shutil import threading import time import zipfile from io import BytesIO from tempfile import gettempdir import requests from babel.dates import format_datetime from flask_babel import gettext as _ from . import constants, logger, config, web_server 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.paused = False # self.pause_cond = threading.Condition(threading.Lock()) self.can_run = threading.Event() self.pause() self.status = -1 self.updateIndex = None # self.run() def get_current_version_info(self): if config.config_updatechannel == constants.UPDATE_STABLE: return self._stable_version_info() return self._nightly_version_info() def get_available_updates(self, request_method, locale): if config.config_updatechannel == constants.UPDATE_STABLE: return self._stable_available_updates(request_method) return self._nightly_available_updates(request_method, locale) def do_work(self): try: self.status = 1 log.debug(u'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(u'Opening zipfile') z = zipfile.ZipFile(BytesIO(r.content)) self.status = 3 log.debug(u'Extracting zipfile') tmp_dir = gettempdir() z.extractall(tmp_dir) foldername = os.path.join(tmp_dir, z.namelist()[0])[:-1] if not os.path.isdir(foldername): self.status = 11 log.info(u'Extracted contents of zipfile not found in temp folder') self.pause() return False self.status = 4 log.debug(u'Replacing files') self.update_source(foldername, constants.BASE_DIR) self.status = 6 log.debug(u'Preparing restart of server') time.sleep(2) web_server.stop(True) self.status = 7 time.sleep(2) return True except requests.exceptions.HTTPError as ex: log.info(u'HTTP Error %s', ex) self.status = 8 except requests.exceptions.ConnectionError: log.info(u'Connection error') self.status = 9 except requests.exceptions.Timeout: log.info(u'Timeout while establishing connection') self.status = 10 except (requests.exceptions.RequestException, zipfile.BadZipFile): self.status = 11 log.info(u'General error') except (IOError, OSError): self.status = 12 log.info(u'Update File Could Not be Saved in Temp Dir') 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 moveallfiles(cls, root_src_dir, root_dst_dir): change_permissions = True new_permissions = os.stat(root_dst_dir) if sys.platform == "win32" or sys.platform == "darwin": change_permissions = False else: log.debug('Update on OS-System : %s', sys.platform) 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): os.makedirs(dst_dir) log.debug('Create-Dir: %s', dst_dir) if change_permissions: # print('Permissions: User '+str(new_permissions.st_uid)+' Group '+str(new_permissions.st_uid)) os.chown(dst_dir, new_permissions.st_uid, new_permissions.st_gid) 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) log.debug('Remove file before copy: %s', dst_file) os.remove(dst_file) else: if change_permissions: permission = new_permissions shutil.move(src_file, dst_dir) log.debug('Move File %s to %s', src_file, dst_dir) 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.debug('Fail change 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 = ( 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 + '.calibre-web.log.swp', os.sep + '_sqlite3.so', os.sep + 'cps' + os.sep + '.HOMEDIR' ) additional_path = self.is_venv() if additional_path: exclude = exclude + (additional_path,) 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) self.moveallfiles(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: log.debug("Delete file %s", item_path) # log_from_thread("Delete file " + item_path) os.remove(item_path) except OSError: log.debug("Could not remove: %s", item_path) shutil.rmtree(source, ignore_errors=True) def is_venv(self): 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: return {'version': constants.NIGHTLY_VERSION[0], 'datetime': constants.NIGHTLY_VERSION[1]} return False @classmethod def _stable_version_info(cls): return constants.STABLE_VERSION # Current version def _nightly_available_updates(self, request_method, locale): 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'] = _(u'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': _(u'No update available. You already have the latest version installed') }) return json.dumps(status) except (TypeError, KeyError): status['message'] = _(u'Unexpected data while reading update information') return json.dumps(status) # a new update is available status['update'] = True 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'] = _(u'HTTP Error') + ' ' + str(e) except requests.exceptions.ConnectionError: 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') if status['message'] != '': return json.dumps(status) # if 'committer' in update_data and 'message' in update_data: try: status['success'] = True status['message'] = _( u'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', locale=locale), 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'] != '': try: parent_commit = update_data['parents'][0] # limit the maximum search depth remaining_parents_cnt = 10 except (IndexError, KeyError): remaining_parents_cnt = 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', locale=locale) parents.append([parent_commit_date, parent_data['message'].replace('\r\n', '

').replace('\n', '

')]) 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 status['history'] = parents[::-1] except (IndexError, KeyError): status['success'] = False status['message'] = _(u'Could not fetch update information') return json.dumps(status) return '' def _stable_available_updates(self, request_method): 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') 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].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', '

')]) newer = True i -= 1 continue if major_version_update < current_version[0]: i -= 1 continue if major_version_update > current_version[0]: # found update update to last version before major update, unless current version is on last version # before major update if i == (len(commit) - 1): i -= 1 if int(commit[i+1]['tag_name'].split('.')[1]) == current_version[1]: parents.append([commit[i]['tag_name'], commit[i]['body'].replace('\r\n', '

').replace('\n', '

')]) 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', '

').replace('\n', '

')]) 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'] break if i == -1 and newer == False: status.update({ 'update': True, 'success': True, 'message': _( u'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 == True: 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[0]['tag_name']), 'history': parents }) self.updateFile = commit[0]['zipball_url'] return json.dumps(status) def _get_request_path(self): if 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: 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: %s', status['message']) return status, commit