diff --git a/README.md b/README.md index e3b6fcdb..896d4535 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d - "Magic Link" login to make it easy to log on eReaders - Login via LDAP, google/github oauth and via proxy authentication -## Quick start +## Installation -#### Install via pip +#### Installation via pip (recommended) 1. Install calibre web via pip with the command `pip install calibreweb` (Depending on your OS and or distro the command could also be `pip3`). 2. Optional features can also be installed via pip, please refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-Windows) for details 3. Calibre-Web can be started afterwards by typing `cps` or `python3 -m cps` @@ -47,18 +47,21 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d #### Manual installation 1. Install dependencies by running `pip3 install --target vendor -r requirements.txt` (python3.x). Alternativly set up a python virtual environment. 2. Execute the command: `python3 cps.py` (or `nohup python3 cps.py` - recommended if you want to exit the terminal window) - + +Issues with Ubuntu: +Please note that running the above install command can fail on some versions of Ubuntu, saying `"can't combine user with prefix"`. This is a [known bug](https://github.com/pypa/pip/issues/3826) and can be remedied by using the command `pip install --system --target vendor -r requirements.txt` instead. + +## Quick start + Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button\ Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/Configuration#using-google-drive-integration) Go to Login page -**Default admin login:**\ +#### Default admin login: *Username:* admin\ *Password:* admin123 -**Issues with Ubuntu:** -Please note that running the above install command can fail on some versions of Ubuntu, saying `"can't combine user with prefix"`. This is a [known bug](https://github.com/pypa/pip/issues/3826) and can be remedied by using the command `pip install --system --target vendor -r requirements.txt` instead. ## Requirements @@ -72,14 +75,7 @@ Optionally, to enable on-the-fly conversion from one ebook format to another whe ## Docker Images -Pre-built Docker images are available in these Docker Hub repositories: - -#### **Technosoft2000 - x64** -+ Docker Hub - [https://hub.docker.com/r/technosoft2000/calibre-web](https://hub.docker.com/r/technosoft2000/calibre-web) -+ Github - [https://github.com/Technosoft2000/docker-calibre-web](https://github.com/Technosoft2000/docker-calibre-web) - - Includes the Calibre `ebook-convert` binary. - + The "path to convertertool" should be set to `/opt/calibre/ebook-convert` +A pre-built Docker image is available in these Docker Hub repository (maintained by the LinuxServer team): #### **LinuxServer - x64, armhf, aarch64** + Docker Hub - [https://hub.docker.com/r/linuxserver/calibre-web](https://hub.docker.com/r/linuxserver/calibre-web) diff --git a/SECURITY.md b/SECURITY.md index 2f36fac8..afaf9b0b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,3 +3,27 @@ ## Reporting a Vulnerability Please report security issues to ozzie.fernandez.isaacs@googlemail.com + +## Supported Versions + +To receive fixes for security vulnerabilities it is required to always upgrade to the latest version of Calibre-Web. See https://github.com/janeczku/calibre-web/releases/latest for the latest release. + +## History + +| Fixed in | Description |CVE number | +| ---------- |---------|---------| +| 3rd July 2018 | Guest access acts as a backdoor|| +| V 0.6.7 |Hardcoded secret key for sessions |CVE-2020-12627 | +| V 0.6.13|Calibre-Web Metadata cross site scripting |CVE-2021-25964| +| V 0.6.13|Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo|| +| V 0.6.13|JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource)|| +| V 0.6.13|JavaScript could get executed in a custom column of type "comment" field || +| V 0.6.13|JavaScript could get executed after converting a book to another format with a title containing javascript code|| +| V 0.6.13|JavaScript could get executed after converting a book to another format with a username containing javascript code|| +| V 0.6.13|JavaScript could get executed in the description series, categories or publishers title|| +| V 0.6.13|JavaScript could get executed in the shelf title|| +| V 0.6.13|Login with the old session cookie after logout. Thanks to @ibarrionuevo|| +| V 0.6.14|CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) || +| V 0.6.14|Cross-Site Scripting vulnerability on typeahead inputs. Thanks to @notdodo|| + + diff --git a/cps/__init__.py b/cps/__init__.py index a36a8925..118b46ff 100644 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -35,6 +35,7 @@ from flask_principal import Principal from . import config_sql, logger, cache_buster, cli, ub, db from .reverseproxy import ReverseProxied from .server import WebServer +from .dep_check import dependency_check try: import lxml @@ -66,6 +67,8 @@ mimetypes.add_type('application/mp4', '.m4a') mimetypes.add_type('application/mp4', '.m4b') mimetypes.add_type('application/ogg', '.ogg') mimetypes.add_type('application/ogg', '.oga') +mimetypes.add_type('text/css', '.css') +mimetypes.add_type('text/javascript; charset=UTF-8', '.js') app = Flask(__name__) app.config.update( @@ -98,6 +101,7 @@ _BABEL_TRANSLATIONS = set() log = logger.create() + from . import services db.CalibreDB.update_config(config) @@ -124,7 +128,11 @@ def create_app(): print('*** "flask-WTF" is needed for calibre-web to run. Please install it using pip: "pip install flask-WTF" ***') web_server.stop(True) sys.exit(7) - + for res in dependency_check() + dependency_check(True): + log.info('*** "{}" version does not fit the requirements. Should: {}, Found: {}, please consider installing required version ***' + .format(res['name'], + res['target'], + res['found'])) app.wsgi_app = ReverseProxied(app.wsgi_app) if os.environ.get('FLASK_DEBUG'): diff --git a/cps/about.py b/cps/about.py index f3c8b95f..ba5a99af 100644 --- a/cps/about.py +++ b/cps/about.py @@ -33,8 +33,9 @@ try: except ImportError: flaskwtf_version = _(u'not installed') -from . import db, calibre_db, converter, uploader, server, isoLanguages, constants +from . import db, calibre_db, converter, uploader, server, isoLanguages, constants, gdriveutils, dep_check from .render_template import render_title_template + try: from flask_login import __version__ as flask_loginVersion except ImportError: @@ -67,38 +68,58 @@ from . import services about = flask.Blueprint('about', __name__) +ret = dict() +req = dep_check.load_dependencys(False) +opt = dep_check.load_dependencys(True) +for i in (req + opt): + ret[i[1]] = i[0] -_VERSIONS = OrderedDict( - Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()), - Python=sys.version, - Calibre_Web=constants.STABLE_VERSION['version'] + ' - ' - + constants.NIGHTLY_VERSION[0].replace('%','%%') + ' - ' - + constants.NIGHTLY_VERSION[1].replace('%','%%'), - WebServer=server.VERSION, - Flask=flask.__version__, - Flask_Login=flask_loginVersion, - Flask_Principal=flask_principal.__version__, - Flask_WTF=flaskwtf_version, - Werkzeug=werkzeug.__version__, - Babel=babel.__version__, - Jinja2=jinja2.__version__, - Requests=requests.__version__, - SqlAlchemy=sqlalchemy.__version__, - pySqlite=sqlite3.version, - SQLite=sqlite3.sqlite_version, - iso639=isoLanguages.__version__, - pytz=pytz.__version__, - Unidecode=unidecode_version, - Scholarly=scholarly_version, - Flask_SimpleLDAP=u'installed' if bool(services.ldap) else None, - python_LDAP=services.ldapVersion if bool(services.ldapVersion) else None, - Goodreads=u'installed' if bool(services.goodreads_support) else None, - jsonschema=services.SyncToken.__version__ if bool(services.SyncToken) else None, - flask_dance=flask_danceVersion, - greenlet=greenlet_Version -) -_VERSIONS.update(uploader.get_versions()) - +if not ret: + _VERSIONS = OrderedDict( + Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()), + Python=sys.version, + Calibre_Web=constants.STABLE_VERSION['version'] + ' - ' + + constants.NIGHTLY_VERSION[0].replace('%','%%') + ' - ' + + constants.NIGHTLY_VERSION[1].replace('%','%%'), + WebServer=server.VERSION, + Flask=flask.__version__, + Flask_Login=flask_loginVersion, + Flask_Principal=flask_principal.__version__, + Flask_WTF=flaskwtf_version, + Werkzeug=werkzeug.__version__, + Babel=babel.__version__, + Jinja2=jinja2.__version__, + Requests=requests.__version__, + SqlAlchemy=sqlalchemy.__version__, + pySqlite=sqlite3.version, + SQLite=sqlite3.sqlite_version, + iso639=isoLanguages.__version__, + pytz=pytz.__version__, + Unidecode=unidecode_version, + Scholarly=scholarly_version, + Flask_SimpleLDAP=u'installed' if bool(services.ldap) else None, + python_LDAP=services.ldapVersion if bool(services.ldapVersion) else None, + Goodreads=u'installed' if bool(services.goodreads_support) else None, + jsonschema=services.SyncToken.__version__ if bool(services.SyncToken) else None, + flask_dance=flask_danceVersion, + greenlet=greenlet_Version + ) + _VERSIONS.update(gdriveutils.get_versions()) + _VERSIONS.update(uploader.get_versions(True)) +else: + _VERSIONS = OrderedDict( + Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()), + Python = sys.version, + Calibre_Web = constants.STABLE_VERSION['version'] + ' - ' + + constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - ' + + constants.NIGHTLY_VERSION[1].replace('%', '%%'), + Werkzeug = werkzeug.__version__, + Jinja2=jinja2.__version__, + pySqlite = sqlite3.version, + SQLite = sqlite3.sqlite_version, + ) + _VERSIONS.update(ret) + _VERSIONS.update(uploader.get_versions(False)) def collect_stats(): _VERSIONS['ebook converter'] = _(converter.get_calibre_version()) @@ -115,5 +136,3 @@ def stats(): series = calibre_db.session.query(db.Series).count() return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=collect_stats(), categorycounter=categorys, seriecounter=series, title=_(u"Statistics"), page="stat") - - diff --git a/cps/admin.py b/cps/admin.py index 2f377187..557d0255 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -1199,7 +1199,7 @@ def _configuration_update_helper(): to_save = request.form.to_dict() try: reboot_required |= _config_int(to_save, "config_port") - + reboot_required |= _config_string(to_save, "config_trustedhosts") reboot_required |= _config_string(to_save, "config_keyfile") if config.config_keyfile and not os.path.isfile(config.config_keyfile): return _configuration_result(_('Keyfile Location is not Valid, Please Enter Correct Path')) diff --git a/cps/config_sql.py b/cps/config_sql.py index 0b45059d..ebc4ca24 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -66,7 +66,7 @@ class _Settings(_Base): config_external_port = Column(Integer, default=constants.DEFAULT_PORT) config_certfile = Column(String) config_keyfile = Column(String) - + config_trustedhosts = Column(String,default='') config_calibre_web_title = Column(String, default=u'Calibre-Web') config_books_per_page = Column(Integer, default=60) config_random_books = Column(Integer, default=4) diff --git a/cps/constants.py b/cps/constants.py index 367bc29d..e37ad900 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -151,7 +151,7 @@ def selected_roles(dictionary): BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, ' 'series_id, languages, publisher') -STABLE_VERSION = {'version': '0.6.14 Beta'} +STABLE_VERSION = {'version': '0.6.15 Beta'} NIGHTLY_VERSION = {} NIGHTLY_VERSION[0] = '$Format:%H$' diff --git a/cps/db.py b/cps/db.py index cf4ab50c..f2acb7a1 100644 --- a/cps/db.py +++ b/cps/db.py @@ -840,7 +840,7 @@ class CalibreDB(): # read search results from calibre-database and return it (function is used for feed and simple search def get_search_results(self, term, offset=None, order=None, limit=None, *join): - order = order or [Books.sort] + order = order[0] if order else [Books.sort] pagination = None result = self.search_query(term, *join).order_by(*order).all() result_count = len(result) diff --git a/cps/dep_check.py b/cps/dep_check.py new file mode 100644 index 00000000..12436d1d --- /dev/null +++ b/cps/dep_check.py @@ -0,0 +1,96 @@ +import os +import re + +from .constants import BASE_DIR +try: + from importlib_metadata import version + importlib = True + ImportNotFound = BaseException +except ImportError: + importlib = False + + +if not importlib: + try: + import pkg_resources + from pkg_resources import DistributionNotFound as ImportNotFound + pkgresources = True + except ImportError as e: + pkgresources = False + +def load_dependencys(optional=False): + deps = list() + if importlib or pkgresources: + if optional: + req_path = os.path.join(BASE_DIR, "optional-requirements.txt") + else: + req_path = os.path.join(BASE_DIR, "requirements.txt") + if os.path.exists(req_path): + with open(req_path, 'r') as f: + for line in f: + if not line.startswith('#') and not line == '\n' and not line.startswith('git'): + res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip()) + try: + if importlib: + dep_version = version(res.group(1)) + else: + dep_version = pkg_resources.get_distribution(res.group(1)).version + except ImportNotFound: + if optional: + continue + dep_version = "not installed" + deps.append([dep_version, res.group(1), res.group(2), res.group(3), res.group(4), res.group(5)]) + return deps + + +def dependency_check(optional=False): + d = list() + deps = load_dependencys(optional) + for dep in deps: + try: + dep_version_int = [int(x) for x in dep[0].split('.')] + low_check = [int(x) for x in dep[3].split('.')] + high_check = [int(x) for x in dep[5].split('.')] + except AttributeError: + high_check = None + except ValueError: + d.append({'name': dep[1], + 'target': "available", + 'found': "Not available" + }) + continue + + if dep[2].strip() == "==": + if dep_version_int != low_check: + d.append({'name': dep[1], + 'found': dep[0], + "target": dep[2] + dep[3]}) + continue + elif dep[2].strip() == ">=": + if dep_version_int < low_check: + d.append({'name': dep[1], + 'found': dep[0], + "target": dep[2] + dep[3]}) + continue + elif dep[2].strip() == ">": + if dep_version_int <= low_check: + d.append({'name': dep[1], + 'found': dep[0], + "target": dep[2] + dep[3]}) + continue + if dep[4] and dep[5]: + if dep[4].strip() == "<": + if dep_version_int >= high_check: + d.append( + {'name': dep[1], + 'found': dep[0], + "target": dep[4] + dep[5]}) + continue + elif dep[4].strip() == "<=": + if dep_version_int > high_check: + d.append( + {'name': dep[1], + 'found': dep[0], + "target": dep[4] + dep[5]}) + continue + return d diff --git a/cps/editbooks.py b/cps/editbooks.py index 383422b1..9e5dd70d 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -503,7 +503,7 @@ def edit_book_languages(languages, book, upload=False, invalid=None): def edit_book_publisher(publishers, book): changed = False - if publishers: + if publishers: publisher = publishers.rstrip().strip() if len(book.publishers) == 0 or (len(book.publishers) > 0 and publisher != book.publishers[0].name): changed |= modify_database_object([publisher], book.publishers, db.Publishers, calibre_db.session, @@ -709,6 +709,7 @@ def handle_title_on_edit(book, book_title): def handle_author_on_edit(book, author_name, update_stored=True): # handle author(s) + # renamed = False input_authors = author_name.split('&') input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors)) # Remove duplicates in authors list @@ -717,6 +718,20 @@ def handle_author_on_edit(book, author_name, update_stored=True): if input_authors == ['']: input_authors = [_(u'Unknown')] # prevent empty Author + # ToDo: Falsch es kann auch sein das der 2. Author in der Liste umbenannt wurde, + # man müsste für alle Authoren schauen + renamed = list() + for in_aut in input_authors: + renamed_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == in_aut).first() + if renamed_author and in_aut != renamed_author.name: + renamed.append(renamed_author.name) + all_books = calibre_db.session.query(db.Books) \ + .filter(db.Books.authors.any(db.Authors.name == renamed_author.name)).all() + sorted_renamed_author = helper.get_sorted_author(renamed_author.name) + sorted_old_author = helper.get_sorted_author(in_aut) + for one_book in all_books: + one_book.author_sort = one_book.author_sort.replace(sorted_renamed_author, sorted_old_author) + change = modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author') # Search for each author if author is in database, if not, author name and sorted author name is generated new @@ -733,7 +748,7 @@ def handle_author_on_edit(book, author_name, update_stored=True): if book.author_sort != sort_authors and update_stored: book.author_sort = sort_authors change = True - return input_authors, change + return input_authors, change, renamed @editbook.route("/admin/book/", methods=['GET', 'POST']) @@ -773,7 +788,7 @@ def edit_book(book_id): # handle book title title_change = handle_title_on_edit(book, to_save["book_title"]) - input_authors, authorchange = handle_author_on_edit(book, to_save["author_name"]) + input_authors, authorchange, renamed = handle_author_on_edit(book, to_save["author_name"]) if authorchange or title_change: edited_books_id = book.id modif_date = True @@ -783,7 +798,8 @@ def edit_book(book_id): error = False if edited_books_id: - error = helper.update_dir_stucture(edited_books_id, config.config_calibre_dir, input_authors[0]) + error = helper.update_dir_structure(edited_books_id, config.config_calibre_dir, input_authors[0], + renamed_author=renamed) if not error: if "cover_url" in to_save: @@ -1096,6 +1112,7 @@ def table_get_custom_enum(c_id): cc = (calibre_db.session.query(db.Custom_Columns) .filter(db.Custom_Columns.id == c_id) .filter(db.Custom_Columns.datatype.notin_(db.cc_exceptions)).one_or_none()) + ret.append({'value': "", 'text': ""}) for idx, en in enumerate(cc.get_display_dict()['enum_values']): ret.append({'value': en, 'text': en}) return json.dumps(ret) @@ -1144,7 +1161,7 @@ def edit_list_book(param): elif param == 'title': sort = book.sort handle_title_on_edit(book, vals.get('value', "")) - helper.update_dir_stucture(book.id, config.config_calibre_dir) + helper.update_dir_structure(book.id, config.config_calibre_dir) ret = Response(json.dumps({'success': True, 'newValue': book.title}), mimetype='application/json') elif param =='sort': @@ -1156,8 +1173,8 @@ def edit_list_book(param): ret = Response(json.dumps({'success': True, 'newValue': book.comments[0].text}), mimetype='application/json') elif param =='authors': - input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true") - helper.update_dir_stucture(book.id, config.config_calibre_dir, input_authors[0]) + input_authors, __, renamed = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true") + helper.update_dir_structure(book.id, config.config_calibre_dir, input_authors[0], renamed_author=renamed) ret = Response(json.dumps({'success': True, 'newValue': ' & '.join([author.replace('|',',') for author in input_authors])}), mimetype='application/json') @@ -1275,7 +1292,7 @@ def table_xchange_author_title(): author_names.append(authr.name.replace('|', ',')) title_change = handle_title_on_edit(book, " ".join(author_names)) - input_authors, authorchange = handle_author_on_edit(book, authors) + input_authors, authorchange, renamed = handle_author_on_edit(book, authors) if authorchange or title_change: edited_books_id = book.id modif_date = True @@ -1284,7 +1301,8 @@ def table_xchange_author_title(): gdriveutils.updateGdriveCalibreFromLocal() if edited_books_id: - helper.update_dir_stucture(edited_books_id, config.config_calibre_dir, input_authors[0]) + helper.update_dir_structure(edited_books_id, config.config_calibre_dir, input_authors[0], + renamed_author=renamed) if modif_date: book.last_modified = datetime.utcnow() try: diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py index d3277814..878c1f9f 100644 --- a/cps/gdriveutils.py +++ b/cps/gdriveutils.py @@ -35,6 +35,15 @@ except ImportError: from sqlalchemy.exc import OperationalError, InvalidRequestError from sqlalchemy.sql.expression import text +try: + from six import __version__ as six_version +except ImportError: + six_version = "not installed" +try: + from httplib2 import __version__ as httplib2_version +except ImportError: + httplib2_version = "not installed" + try: from apiclient import errors from httplib2 import ServerNotFoundError @@ -659,3 +668,8 @@ def get_error_text(client_secrets=None): return 'Callback url (redirect url) is missing in client_secrets.json' if client_secrets: client_secrets.update(filedata['web']) + + +def get_versions(): + return {'six': six_version, + 'httplib2': httplib2_version} diff --git a/cps/helper.py b/cps/helper.py index df7019e6..2cc7591b 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -334,9 +334,22 @@ def delete_book_file(book, calibrepath, book_format=None): id=book.id, path=book.path) +# was muss gemacht werden: +# Die Autorennamen müssen separiert werden und von dupletten bereinigt werden. +# Es muss geprüft werden: +# - ob es die alten Autoren mit dem letzten Buch verknüpft waren, dann müssen sie gelöscht werden +# - ob es neue Autoren sind, dann müssen sie angelegt werden -> macht modify_database_object +# - ob es bestehende Autoren sind welche umbenannt wurden -> Groß Kleinschreibung, dann muss: +# für jedes Buch und jeder Autor welcher umbenannt wurde: +# - Autorensortierung angepasst werden +# - Pfad im Buch angepasst werden +# - Dateiname in Datatabelle angepasst werden, sowie die Dateien umbenannt werden +# - Dateipfade Autor umbenannt werden +# die letzten Punkte treffen auch zu wenn es sich um einen normalen Autoränderungsvorgang handelt kann man also generell +# behandeln # Moves files in file storage during author/title rename, or from temp dir to file storage -def update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepath, db_filename): +def update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepath, db_filename, renamed_author): # get book database entry from id, if original path overwrite source with original_filepath localbook = calibre_db.get_book(book_id) if orignal_filepath: @@ -352,21 +365,32 @@ def update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepa # Create new titledir from database and add id if first_author: new_authordir = get_valid_filename(first_author) + for r in renamed_author: + if first_author.lower() == r.lower(): + try: + new_author_path = os.path.join(calibrepath, new_authordir) + old_author_path = os.path.join(calibrepath, r) + shutil.move(os.path.normcase(old_author_path), os.path.normcase(new_author_path)) + except (OSError) as ex: + log.error("Rename author from: %s to %s: %s", r, new_authordir, ex) + log.debug(ex, exc_info=True) + return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s", + src=old_author_path, dest=new_author_path, error=str(ex)) else: new_authordir = get_valid_filename(localbook.authors[0].name) new_titledir = get_valid_filename(localbook.title) + " (" + str(book_id) + ")" if titledir != new_titledir or authordir != new_authordir or orignal_filepath: new_path = os.path.join(calibrepath, new_authordir, new_titledir) - new_name = get_valid_filename(localbook.title) + ' - ' + get_valid_filename(new_authordir) + new_name = get_valid_filename(localbook.title) + ' - ' + new_authordir try: if orignal_filepath: if not os.path.isdir(new_path): os.makedirs(new_path) shutil.move(os.path.normcase(path), os.path.normcase(os.path.join(new_path, db_filename))) log.debug("Moving title: %s to %s/%s", path, new_path, new_name) - # Check new path is not valid path else: + # Check new path is not valid path if not os.path.exists(new_path): # move original path to new path log.debug("Moving title: %s to %s", path, new_path) @@ -379,8 +403,6 @@ def update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepa shutil.move(os.path.normcase(os.path.join(dir_name, file)), os.path.normcase(os.path.join(new_path + dir_name[len(path):], file))) # os.unlink(os.path.normcase(os.path.join(dir_name, file))) - # change location in database to new author/title path - localbook.path = os.path.join(new_authordir, new_titledir).replace('\\','/') except (OSError) as ex: log.error("Rename title from: %s to %s: %s", path, new_path, ex) log.debug(ex, exc_info=True) @@ -389,15 +411,23 @@ def update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepa # Rename all files from old names to new names try: - for file_format in localbook.data: - shutil.move(os.path.normcase( - os.path.join(new_path, file_format.name + '.' + file_format.format.lower())), - os.path.normcase(os.path.join(new_path, new_name + '.' + file_format.format.lower()))) - file_format.name = new_name - if not orignal_filepath and len(os.listdir(os.path.dirname(path))) == 0: + all_books = calibre_db.session.query(db.Books)\ + .filter(db.Books.authors.any(db.Authors.name == renamed_author)).all() + for book in all_books: + all_titledir = book.path.split('/')[1] + all_new_path = os.path.join(calibrepath, new_authordir, all_titledir) + all_new_name = get_valid_filename(book.title) + ' - ' + new_authordir + # change location in database to new author/title path + book.path = os.path.join(new_authordir, all_titledir).replace('\\', '/') + for file_format in book.data: + shutil.move(os.path.normcase( + os.path.join(all_new_path, file_format.name + '.' + file_format.format.lower())), + os.path.normcase(os.path.join(all_new_path, all_new_name + '.' + file_format.format.lower()))) + file_format.name = all_new_name + if not renamed_author and not orignal_filepath and len(os.listdir(os.path.dirname(path))) == 0: shutil.rmtree(os.path.dirname(path)) except (OSError) as ex: - log.error("Rename file in path %s to %s: %s", new_path, new_name, ex) + log.error("Rename file in path %s to %s: %s", all_new_path, all_new_name, ex) log.debug(ex, exc_info=True) return _("Rename file in path '%(src)s' to '%(dest)s' failed with error: %(error)s", src=new_path, dest=new_name, error=str(ex)) @@ -528,11 +558,21 @@ def valid_email(email): # ################################# External interface ################################# -def update_dir_stucture(book_id, calibrepath, first_author=None, orignal_filepath=None, db_filename=None): +def update_dir_structure(book_id, + calibrepath, + first_author=None, + orignal_filepath=None, + db_filename=None, + renamed_author=False): if config.config_use_google_drive: + # ToDo: rename author on gdrive return update_dir_structure_gdrive(book_id, first_author) else: - return update_dir_structure_file(book_id, calibrepath, first_author, orignal_filepath, db_filename) + return update_dir_structure_file(book_id, + calibrepath, + first_author, + orignal_filepath, + db_filename, renamed_author) def delete_book(book, calibrepath, book_format): diff --git a/cps/static/css/style.css b/cps/static/css/style.css index 32c6f225..bf2257e6 100644 --- a/cps/static/css/style.css +++ b/cps/static/css/style.css @@ -169,9 +169,14 @@ table .bg-dark-danger a { color: #fff; } position: static; } +.container-fluid .book .cover span .img { + bottom: 0; + height: 100%; + position: absolute; +} + .container-fluid .book .cover span img { border: 1px solid #fff; - bottom: 0; position: relative; height: 100%; @@ -246,8 +251,10 @@ span.glyphicon.glyphicon-tags { } .cover .read { - left: auto; - right: 2px; + position: relative; + top: -20px; + /*left: auto; + right: 2px;*/ width: 17px; height: 17px; display: inline-block; diff --git a/cps/static/js/filter_grid.js b/cps/static/js/filter_grid.js index 623ffdc1..14d60f27 100644 --- a/cps/static/js/filter_grid.js +++ b/cps/static/js/filter_grid.js @@ -21,47 +21,59 @@ var $list = $("#list").isotope({ itemSelector: ".book", layoutMode: "fitRows", getSortData: { - title: ".title", - } + title: ".title" + }, }); + $("#desc").click(function() { if (direction === 0) { return; } + $("#asc").removeClass("active"); + $("#desc").addClass("active"); + var page = $(this).data("id"); $.ajax({ method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../ajax/view", + url: getPath() + "/ajax/view", data: "{\"" + page + "\": {\"dir\": \"desc\"}}", }); + // invert sorting order to make already inverted start order working $list.isotope({ sortBy: "name", - sortAscending: true + sortAscending: !$list.data('isotope').options.sortAscending }); + direction = 0; }); $("#asc").click(function() { if (direction === 1) { return; } + $("#desc").removeClass("active"); + $("#asc").addClass("active"); + var page = $(this).data("id"); $.ajax({ method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../ajax/view", + url: getPath() + "/ajax/view", data: "{\"" + page + "\": {\"dir\": \"asc\"}}", }); $list.isotope({ sortBy: "name", - sortAscending: false + sortAscending: !$list.data('isotope').options.sortAscending }); + direction = 1; }); $("#all").click(function() { + $(".char").removeClass("active"); + $("#all").addClass("active"); // go through all elements and make them visible $list.isotope({ filter: function() { return true; @@ -70,6 +82,9 @@ $("#all").click(function() { }); $(".char").click(function() { + $(".char").removeClass("active"); + $(this).addClass("active"); + $("#all").removeClass("active"); var character = this.innerText; $list.isotope({ filter: function() { return this.attributes["data-id"].value.charAt(0).toUpperCase() === character; diff --git a/cps/static/js/filter_list.js b/cps/static/js/filter_list.js index b8f79f4e..747f98fa 100644 --- a/cps/static/js/filter_list.js +++ b/cps/static/js/filter_list.js @@ -19,6 +19,7 @@ var direction = $("#asc").data('order'); // 0=Descending order; 1= ascending or var sort = 0; // Show sorted entries $("#sort_name").click(function() { + $("#sort_name").toggleClass("active"); var className = $("h1").attr("Class") + "_sort_name"; var obj = {}; obj[className] = sort; @@ -68,12 +69,15 @@ $("#desc").click(function() { if (direction === 0) { return; } + $("#asc").removeClass("active"); + $("#desc").addClass("active"); + var page = $(this).data("id"); $.ajax({ method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../ajax/view", + url: getPath() + "/ajax/view", data: "{\"" + page + "\": {\"dir\": \"desc\"}}", }); var index = 0; @@ -112,16 +116,18 @@ $("#desc").click(function() { $("#asc").click(function() { - if (direction === 1) { return; } + $("#desc").removeClass("active"); + $("#asc").addClass("active"); + var page = $(this).data("id"); $.ajax({ method:"post", contentType: "application/json; charset=utf-8", dataType: "json", - url: window.location.pathname + "/../../ajax/view", + url: getPath() + "/ajax/view", data: "{\"" + page + "\": {\"dir\": \"asc\"}}", }); var index = 0; @@ -159,6 +165,8 @@ $("#asc").click(function() { }); $("#all").click(function() { + $("#all").addClass("active"); + $(".char").removeClass("active"); var cnt = $("#second").contents(); $("#list").append(cnt); // Find count of middle element @@ -176,6 +184,9 @@ $("#all").click(function() { }); $(".char").click(function() { + $(".char").removeClass("active"); + $(this).addClass("active"); + $("#all").removeClass("active"); var character = this.innerText; var count = 0; var index = 0; diff --git a/cps/static/js/get_meta.js b/cps/static/js/get_meta.js index f64be699..51ab740d 100644 --- a/cps/static/js/get_meta.js +++ b/cps/static/js/get_meta.js @@ -28,14 +28,17 @@ $(function () { function populateForm (book) { tinymce.get("description").setContent(book.description); - var uniqueTags = []; + var uniqueTags = $.map($("#tags").val().split(","), $.trim); + if ( uniqueTags.length == 1 && uniqueTags[0] == "") { + uniqueTags = []; + } $.each(book.tags, function(i, el) { if ($.inArray(el, uniqueTags) === -1) uniqueTags.push(el); }); var ampSeparatedAuthors = (book.authors || []).join(" & "); $("#bookAuthor").val(ampSeparatedAuthors); $("#book_title").val(book.title); - $("#tags").val(uniqueTags.join(",")); + $("#tags").val(uniqueTags.join(", ")); $("#rating").data("rating").setValue(Math.round(book.rating)); if(book.cover !== null){ $(".cover img").attr("src", book.cover); diff --git a/cps/static/js/table.js b/cps/static/js/table.js index ba664d64..fbb4df3e 100644 --- a/cps/static/js/table.js +++ b/cps/static/js/table.js @@ -652,6 +652,9 @@ function singlecheckboxFormatter(value, row){ } function ratingFormatter(value, row) { + if (value == 0) { + return ""; + } return (value/2); } diff --git a/cps/tasks/convert.py b/cps/tasks/convert.py index 08fb1644..ada53005 100644 --- a/cps/tasks/convert.py +++ b/cps/tasks/convert.py @@ -151,6 +151,7 @@ class TaskConvert(CalibreTask): local_db.session.rollback() log.error("Database error: %s", e) local_db.session.close() + self._handleError(error_message) return self.results['path'] = cur_book.path self.title = cur_book.title diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 9a941594..f99c0938 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -16,7 +16,9 @@ {{_('Downloads')}} {{_('Admin')}} {{_('Password')}} + {% if config.config_upload %} {{_('Upload')}} + {% endif %} {{_('Download')}} {{_('View Books')}} {{_('Edit')}} @@ -32,7 +34,9 @@ {{user.downloads.count()}} {{ display_bool_setting(user.role_admin()) }} {{ display_bool_setting(user.role_passwd()) }} + {% if config.config_upload %} {{ display_bool_setting(user.role_upload()) }} + {% endif %} {{ display_bool_setting(user.role_download()) }} {{ display_bool_setting(user.role_viewer()) }} {{ display_bool_setting(user.role_edit()) }} diff --git a/cps/templates/author.html b/cps/templates/author.html index d82b2ebd..f2b71eab 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -22,13 +22,13 @@ {% if author is not none %}

{{_("In Library")}}

{% endif %} - diff --git a/cps/templates/config_view_edit.html b/cps/templates/config_view_edit.html index ebfb5362..32509e88 100644 --- a/cps/templates/config_view_edit.html +++ b/cps/templates/config_view_edit.html @@ -95,17 +95,21 @@ + {% if config.config_upload %}
+ {% endif %}
- +
-
- - +
+
+ + +
diff --git a/cps/templates/grid.html b/cps/templates/grid.html index 5de80caf..b9d40961 100644 --- a/cps/templates/grid.html +++ b/cps/templates/grid.html @@ -2,23 +2,22 @@ {% block body %}

{{_(title)}}

-