mirror of
https://github.com/janeczku/calibre-web
synced 2024-12-13 11:40:30 +00:00
Merge branch 'janeczku:master' into master
This commit is contained in:
commit
98967c926b
10
README.md
10
README.md
@ -1,10 +1,3 @@
|
||||
# Short Notice from the maintainer
|
||||
|
||||
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
||||
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
|
||||
I have turned off all notifications from Github/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
|
||||
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
||||
|
||||
# Calibre-Web
|
||||
|
||||
Calibre-Web is a web app that offers a clean and intuitive interface for browsing, reading, and downloading eBooks using a valid [Calibre](https://calibre-ebook.com) database.
|
||||
@ -89,8 +82,9 @@ Refer to the Wiki for additional installation examples: [manual installation](ht
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.5+
|
||||
- Python 3.7+
|
||||
- [Imagemagick](https://imagemagick.org/script/download.php) for cover extraction from EPUBs (Windows users may need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction)
|
||||
- Windows users need to install [libmagic for 32bit python](https://gnuwin32.sourceforge.net/downlinks/file.php) or [libmagic for 64bit python](https://github.com/nscaife/file-windows/releases/tag/20170108), depending on the python version; The files need to be installed in path (e.g. script folder of your Calibre-Web venv, or in the root folder of Calibre-Web
|
||||
- Optional: [Calibre desktop program](https://calibre-ebook.com/download) for on-the-fly conversion and metadata editing (set "calibre's converter tool" path on the setup page)
|
||||
- Optional: [Kepubify tool](https://github.com/pgaskin/kepubify/releases/latest) for Kobo device support (place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows)
|
||||
|
||||
|
75
SECURITY.md
75
SECURITY.md
@ -10,41 +10,46 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
|
||||
|
||||
## 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) |CVE-2021-25965|
|
||||
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
|
||||
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
|
||||
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
|
||||
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
|
||||
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
|
||||
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
|
||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
|
||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
|
||||
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
|
||||
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
|
||||
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
|
||||
| V 0.6.20 | Credentials for emails are now stored encrypted ||
|
||||
| V 0.6.20 | Login is rate limited ||
|
||||
| V 0.6.20 | Passwordstrength can be forced ||
|
||||
| V 0.6.21 | SMTP server credentials are no longer returned to client ||
|
||||
| V 0.6.21 | Cross-site scripting (XSS) stored in href bypasses filter using data wrapper no longer possible ||
|
||||
| V 0.6.21 | Cross-site scripting (XSS) is no longer possible via pathchooser ||
|
||||
| V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
|
||||
| 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) |CVE-2021-25965|
|
||||
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
|
||||
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
|
||||
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
|
||||
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
|
||||
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
|
||||
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
|
||||
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
|
||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
|
||||
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
|
||||
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
|
||||
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
|
||||
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
|
||||
| V 0.6.20 | Credentials for emails are now stored encrypted ||
|
||||
| V 0.6.20 | Login is rate limited ||
|
||||
| V 0.6.20 | Passwordstrength can be forced ||
|
||||
| V 0.6.21 | SMTP server credentials are no longer returned to client ||
|
||||
| V 0.6.21 | Cross-site scripting (XSS) stored in href bypasses filter using data wrapper no longer possible ||
|
||||
| V 0.6.21 | Cross-site scripting (XSS) is no longer possible via pathchooser ||
|
||||
| V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
|
||||
| V 0.6.22 | Upload mimetype is checked to prevent malicious file content in the books library ||
|
||||
| V 0.6.22 | Cross-site scripting (XSS) stored in comments section is prevented better (switching from lxml to bleach for sanitizing strings) ||
|
||||
| V 0.6.23 | Cookies are no longer stored for opds basic authentication and proxy authentication ||
|
||||
|
||||
|
||||
|
||||
|
||||
## Statement regarding Log4j (CVE-2021-44228 and related)
|
||||
|
20
cps.py
20
cps.py
@ -30,19 +30,15 @@ from cps.main import main
|
||||
|
||||
def hide_console_windows():
|
||||
import ctypes
|
||||
import os
|
||||
|
||||
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
|
||||
if hwnd != 0:
|
||||
try:
|
||||
import win32process
|
||||
except ImportError:
|
||||
print("To hide console window install 'pywin32' using 'pip install pywin32'")
|
||||
return
|
||||
ctypes.windll.user32.ShowWindow(hwnd, 0)
|
||||
ctypes.windll.kernel32.CloseHandle(hwnd)
|
||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||
os.system('taskkill /PID ' + str(pid) + ' /f')
|
||||
kernel32 = ctypes.WinDLL('kernel32')
|
||||
user32 = ctypes.WinDLL('user32')
|
||||
|
||||
SW_HIDE = 0
|
||||
|
||||
hWnd = kernel32.GetConsoleWindow()
|
||||
if hWnd:
|
||||
user32.ShowWindow(hWnd, SW_HIDE)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@ -20,11 +20,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask_login import LoginManager, confirm_login
|
||||
from flask import session, current_app
|
||||
from flask_login.utils import decode_cookie
|
||||
from flask_login.signals import user_loaded_from_cookie
|
||||
from .cw_login import LoginManager
|
||||
from flask import session
|
||||
|
||||
|
||||
class MyLoginManager(LoginManager):
|
||||
@ -36,18 +33,5 @@ class MyLoginManager(LoginManager):
|
||||
return super(). _session_protection_failed()
|
||||
return False
|
||||
|
||||
def _load_user_from_remember_cookie(self, cookie):
|
||||
user_id = decode_cookie(cookie)
|
||||
if user_id is not None:
|
||||
session["_user_id"] = user_id
|
||||
session["_fresh"] = False
|
||||
user = None
|
||||
if self._user_callback:
|
||||
user = self._user_callback(user_id)
|
||||
if user is not None:
|
||||
app = current_app._get_current_object()
|
||||
user_loaded_from_cookie.send(app, user=user)
|
||||
# if session was restored from remember me cookie make login valid
|
||||
confirm_login()
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
|
11
cps/__init__.py
Executable file → Normal file
11
cps/__init__.py
Executable file → Normal file
@ -72,6 +72,9 @@ mimetypes.add_type('application/mpeg', '.mpeg')
|
||||
mimetypes.add_type('audio/mpeg', '.mp3')
|
||||
mimetypes.add_type('audio/x-m4a', '.m4a')
|
||||
mimetypes.add_type('audio/x-m4a', '.m4b')
|
||||
mimetypes.add_type('audio/x-hx-aac-adts', '.aac')
|
||||
mimetypes.add_type('audio/vnd.dolby.dd-raw', '.ac3')
|
||||
mimetypes.add_type('video/x-ms-asf', '.asf')
|
||||
mimetypes.add_type('audio/ogg', '.ogg')
|
||||
mimetypes.add_type('application/ogg', '.oga')
|
||||
mimetypes.add_type('text/css', '.css')
|
||||
@ -83,9 +86,11 @@ log = logger.create()
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE='Lax',
|
||||
REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest
|
||||
WTF_CSRF_SSL_STRICT=False
|
||||
SESSION_COOKIE_SAMESITE='Strict',
|
||||
REMEMBER_COOKIE_SAMESITE='Strict',
|
||||
WTF_CSRF_SSL_STRICT=False,
|
||||
SESSION_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "session",
|
||||
REMEMBER_COOKIE_NAME=os.environ.get('COOKIE_PREFIX', "") + "remember_token"
|
||||
)
|
||||
|
||||
lm = MyLoginManager()
|
||||
|
12
cps/about.py
12
cps/about.py
@ -23,15 +23,16 @@
|
||||
import sys
|
||||
import platform
|
||||
import sqlite3
|
||||
import importlib
|
||||
from collections import OrderedDict
|
||||
|
||||
import flask
|
||||
import flask_login
|
||||
import jinja2
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from . import db, calibre_db, converter, uploader, constants, dep_check
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
|
||||
about = flask.Blueprint('about', __name__)
|
||||
@ -41,8 +42,11 @@ req = dep_check.load_dependencies(False)
|
||||
opt = dep_check.load_dependencies(True)
|
||||
for i in (req + opt):
|
||||
modules[i[1]] = i[0]
|
||||
modules['Jinja2'] = jinja2.__version__
|
||||
modules['pySqlite'] = sqlite3.version
|
||||
modules['Jinja2'] = importlib.metadata.version("jinja2")
|
||||
try:
|
||||
modules['pySqlite'] = sqlite3.version
|
||||
except Exception:
|
||||
pass
|
||||
modules['SQLite'] = sqlite3.sqlite_version
|
||||
sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold())))
|
||||
|
||||
@ -74,7 +78,7 @@ def collect_stats():
|
||||
|
||||
|
||||
@about.route("/stats")
|
||||
@flask_login.login_required
|
||||
@user_login_required
|
||||
def stats():
|
||||
counter = calibre_db.session.query(db.Books).count()
|
||||
authors = calibre_db.session.query(db.Authors).count()
|
||||
|
144
cps/admin.py
Executable file → Normal file
144
cps/admin.py
Executable file → Normal file
@ -34,10 +34,9 @@ from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
|
||||
from markupsafe import Markup
|
||||
from flask_login import login_required, current_user, logout_user
|
||||
from .cw_login import current_user
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
|
||||
from flask import session as flask_session
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
|
||||
@ -52,8 +51,10 @@ from .embed_helper import get_calibre_binarypath
|
||||
from .gdriveutils import is_gdrive_ready, gdrive_support
|
||||
from .render_template import render_title_template, get_sidebar_config
|
||||
from .services.worker import WorkerThread
|
||||
from .usermanagement import user_login_required
|
||||
from .babel import get_available_translations, get_available_locale, get_user_locale_language
|
||||
from . import debug_info
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@ -103,13 +104,13 @@ def admin_required(f):
|
||||
|
||||
@admi.before_app_request
|
||||
def before_request():
|
||||
try:
|
||||
if not ub.check_user_session(current_user.id,
|
||||
flask_session.get('_id')) and 'opds' not in request.path \
|
||||
and config.config_session == 1:
|
||||
logout_user()
|
||||
except AttributeError:
|
||||
pass # ? fails on requesting /ajax/emailstat during restart ?
|
||||
#try:
|
||||
#if not ub.check_user_session(current_user.id,
|
||||
# flask_session.get('_id')) and 'opds' not in request.path \
|
||||
# and config.config_session == 1:
|
||||
# logout_user()
|
||||
#except AttributeError:
|
||||
# pass # ? fails on requesting /ajax/emailstat during restart ?
|
||||
g.constants = constants
|
||||
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
|
||||
g.allow_registration = config.config_public_reg
|
||||
@ -129,14 +130,14 @@ def before_request():
|
||||
return redirect(url_for('admin.db_configuration'))
|
||||
|
||||
|
||||
@admi.route("/admin")
|
||||
@login_required
|
||||
def admin_forbidden():
|
||||
abort(403)
|
||||
#@admi.route("/admin")
|
||||
#@user_login_required
|
||||
#def admin_forbidden():
|
||||
# abort(403)
|
||||
|
||||
|
||||
@admi.route("/shutdown", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def shutdown():
|
||||
task = request.get_json().get('parameter', -1)
|
||||
@ -165,7 +166,7 @@ def shutdown():
|
||||
|
||||
|
||||
@admi.route("/metadata_backup", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def queue_metadata_backup():
|
||||
show_text = {}
|
||||
@ -189,7 +190,7 @@ def reconnect():
|
||||
|
||||
@admi.route("/ajax/updateThumbnails", methods=['POST'])
|
||||
@admin_required
|
||||
@login_required
|
||||
@user_login_required
|
||||
def update_thumbnails():
|
||||
content = config.get_scheduled_task_settings()
|
||||
if content['schedule_generate_book_covers']:
|
||||
@ -199,7 +200,7 @@ def update_thumbnails():
|
||||
|
||||
|
||||
@admi.route("/admin/view")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def admin():
|
||||
version = updater_thread.get_current_version_info()
|
||||
@ -233,7 +234,7 @@ def admin():
|
||||
|
||||
|
||||
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def db_configuration():
|
||||
if request.method == "POST":
|
||||
@ -242,7 +243,7 @@ def db_configuration():
|
||||
|
||||
|
||||
@admi.route("/admin/config", methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def configuration():
|
||||
return render_title_template("config_edit.html",
|
||||
@ -253,28 +254,28 @@ def configuration():
|
||||
|
||||
|
||||
@admi.route("/admin/ajaxconfig", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def ajax_config():
|
||||
return _configuration_update_helper()
|
||||
|
||||
|
||||
@admi.route("/admin/ajaxdbconfig", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def ajax_db_config():
|
||||
return _db_configuration_update_helper()
|
||||
|
||||
|
||||
@admi.route("/admin/alive", methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def calibreweb_alive():
|
||||
return "", 200
|
||||
|
||||
|
||||
@admi.route("/admin/viewconfig")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def view_configuration():
|
||||
read_column = calibre_db.session.query(db.CustomColumns) \
|
||||
@ -291,7 +292,7 @@ def view_configuration():
|
||||
|
||||
|
||||
@admi.route("/admin/usertable")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_user_table():
|
||||
visibility = current_user.view_settings.get('useredit', {})
|
||||
@ -326,7 +327,7 @@ def edit_user_table():
|
||||
|
||||
|
||||
@admi.route("/ajax/listusers")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def list_users():
|
||||
off = int(request.args.get("offset") or 0)
|
||||
@ -377,7 +378,7 @@ def list_users():
|
||||
|
||||
|
||||
@admi.route("/ajax/deleteuser", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def delete_user():
|
||||
user_ids = request.form.to_dict(flat=False)
|
||||
@ -412,7 +413,7 @@ def delete_user():
|
||||
|
||||
|
||||
@admi.route("/ajax/getlocale")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def table_get_locale():
|
||||
locale = get_available_locale()
|
||||
@ -424,7 +425,7 @@ def table_get_locale():
|
||||
|
||||
|
||||
@admi.route("/ajax/getdefaultlanguage")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def table_get_default_lang():
|
||||
languages = calibre_db.speaking_language()
|
||||
@ -436,7 +437,7 @@ def table_get_default_lang():
|
||||
|
||||
|
||||
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_list_user(param):
|
||||
vals = request.form.to_dict(flat=False)
|
||||
@ -463,9 +464,9 @@ def edit_list_user(param):
|
||||
if 'value[]' in vals:
|
||||
setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]']))
|
||||
else:
|
||||
setattr(user, param, vals['value'].strip())
|
||||
setattr(user, param, strip_whitespaces(vals['value']))
|
||||
else:
|
||||
vals['value'] = vals['value'].strip()
|
||||
vals['value'] = strip_whitespaces(vals['value'])
|
||||
if param == 'name':
|
||||
if user.name == "Guest":
|
||||
raise Exception(_("Guest Name can't be changed"))
|
||||
@ -541,7 +542,7 @@ def edit_list_user(param):
|
||||
|
||||
|
||||
@admi.route("/ajax/user_table_settings", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def update_table_settings():
|
||||
current_user.view_settings['useredit'] = json.loads(request.data)
|
||||
@ -558,7 +559,7 @@ def update_table_settings():
|
||||
|
||||
|
||||
@admi.route("/admin/viewconfig", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def update_view_configuration():
|
||||
to_save = request.form.to_dict()
|
||||
@ -603,7 +604,7 @@ def update_view_configuration():
|
||||
|
||||
|
||||
@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def load_dialogtexts(element_id):
|
||||
texts = {"header": "", "main": "", "valid": 1}
|
||||
if element_id == "config_delete_kobo_token":
|
||||
@ -639,7 +640,7 @@ def load_dialogtexts(element_id):
|
||||
|
||||
|
||||
@admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_domain(allow):
|
||||
# POST /post
|
||||
@ -653,7 +654,7 @@ def edit_domain(allow):
|
||||
|
||||
|
||||
@admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def add_domain(allow):
|
||||
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
|
||||
@ -667,7 +668,7 @@ def add_domain(allow):
|
||||
|
||||
|
||||
@admi.route("/ajax/deletedomain", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def delete_domain():
|
||||
try:
|
||||
@ -685,12 +686,12 @@ def delete_domain():
|
||||
|
||||
|
||||
@admi.route("/ajax/domainlist/<int:allow>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def list_domain(allow):
|
||||
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
|
||||
json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer])
|
||||
js = json.dumps(json_dumps.replace('"', "'")).lstrip('"').strip('"')
|
||||
js = json.dumps(json_dumps.replace('"', "'")).strip('"')
|
||||
response = make_response(js.replace("'", '"'))
|
||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
return response
|
||||
@ -698,7 +699,7 @@ def list_domain(allow):
|
||||
|
||||
@admi.route("/ajax/editrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
|
||||
@admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_restriction(res_type, user_id):
|
||||
element = request.form.to_dict()
|
||||
@ -764,14 +765,14 @@ def edit_restriction(res_type, user_id):
|
||||
|
||||
|
||||
@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def add_user_0_restriction(res_type):
|
||||
return add_restriction(res_type, 0)
|
||||
|
||||
|
||||
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def add_restriction(res_type, user_id):
|
||||
element = request.form.to_dict()
|
||||
@ -817,14 +818,14 @@ def add_restriction(res_type, user_id):
|
||||
|
||||
|
||||
@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def delete_user_0_restriction(res_type):
|
||||
return delete_restriction(res_type, 0)
|
||||
|
||||
|
||||
@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def delete_restriction(res_type, user_id):
|
||||
element = request.form.to_dict()
|
||||
@ -872,7 +873,7 @@ def delete_restriction(res_type, user_id):
|
||||
|
||||
@admi.route("/ajax/listrestriction/<int:res_type>", defaults={"user_id": 0})
|
||||
@admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def list_restriction(res_type, user_id):
|
||||
if res_type == 0: # Tags as template
|
||||
@ -916,20 +917,20 @@ def list_restriction(res_type, user_id):
|
||||
|
||||
|
||||
@admi.route("/ajax/fullsync", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def ajax_self_fullsync():
|
||||
return do_full_kobo_sync(current_user.id)
|
||||
|
||||
|
||||
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def ajax_fullsync(userid):
|
||||
return do_full_kobo_sync(userid)
|
||||
|
||||
|
||||
@admi.route("/ajax/pathchooser/")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def ajax_pathchooser():
|
||||
return pathchooser()
|
||||
@ -1100,7 +1101,7 @@ def _config_checkbox_int(to_save, x):
|
||||
|
||||
|
||||
def _config_string(to_save, x):
|
||||
return config.set_from_dictionary(to_save, x, lambda y: y.strip().strip(u'\u200B\u200C\u200D\ufeff') if y else y)
|
||||
return config.set_from_dictionary(to_save, x, lambda y: strip_whitespaces(y) if y else y)
|
||||
|
||||
|
||||
def _configuration_gdrive_helper(to_save):
|
||||
@ -1246,7 +1247,7 @@ def _configuration_ldap_helper(to_save):
|
||||
|
||||
|
||||
@admi.route("/ajax/simulatedbchange", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def simulatedbchange():
|
||||
db_change, db_valid = _db_simulate_change()
|
||||
@ -1254,7 +1255,7 @@ def simulatedbchange():
|
||||
|
||||
|
||||
@admi.route("/admin/user/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def new_user():
|
||||
content = ub.User()
|
||||
@ -1276,7 +1277,7 @@ def new_user():
|
||||
|
||||
|
||||
@admi.route("/admin/mailsettings", methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_mailsettings():
|
||||
content = config.get_mail_settings()
|
||||
@ -1285,7 +1286,7 @@ def edit_mailsettings():
|
||||
|
||||
|
||||
@admi.route("/admin/mailsettings", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def update_mailsettings():
|
||||
to_save = request.form.to_dict()
|
||||
@ -1311,9 +1312,9 @@ def update_mailsettings():
|
||||
if to_save.get("mail_password_e", ""):
|
||||
_config_string(to_save, "mail_password_e")
|
||||
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
|
||||
config.mail_server = to_save.get('mail_server', "").strip()
|
||||
config.mail_from = to_save.get('mail_from', "").strip()
|
||||
config.mail_login = to_save.get('mail_login', "").strip()
|
||||
config.mail_server = strip_whitespaces(to_save.get('mail_server', ""))
|
||||
config.mail_from = strip_whitespaces(to_save.get('mail_from', ""))
|
||||
config.mail_login = strip_whitespaces(to_save.get('mail_login', ""))
|
||||
try:
|
||||
config.save()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
@ -1342,7 +1343,7 @@ def update_mailsettings():
|
||||
|
||||
|
||||
@admi.route("/admin/scheduledtasks")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_scheduledtasks():
|
||||
content = config.get_scheduled_task_settings()
|
||||
@ -1363,7 +1364,7 @@ def edit_scheduledtasks():
|
||||
|
||||
|
||||
@admi.route("/admin/scheduledtasks", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def update_scheduledtasks():
|
||||
error = False
|
||||
@ -1406,7 +1407,7 @@ def update_scheduledtasks():
|
||||
|
||||
|
||||
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def edit_user(user_id):
|
||||
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
|
||||
@ -1435,7 +1436,7 @@ def edit_user(user_id):
|
||||
|
||||
|
||||
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def reset_user_password(user_id):
|
||||
if current_user is not None and current_user.is_authenticated:
|
||||
@ -1453,7 +1454,7 @@ def reset_user_password(user_id):
|
||||
|
||||
|
||||
@admi.route("/admin/logfile")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def view_logfile():
|
||||
logfiles = {0: logger.get_logfile(config.config_logfile),
|
||||
@ -1467,7 +1468,7 @@ def view_logfile():
|
||||
|
||||
|
||||
@admi.route("/ajax/log/<int:logtype>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def send_logfile(logtype):
|
||||
if logtype == 1:
|
||||
@ -1483,7 +1484,7 @@ def send_logfile(logtype):
|
||||
|
||||
|
||||
@admi.route("/admin/logdownload/<int:logtype>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def download_log(logtype):
|
||||
if logtype == 0:
|
||||
@ -1498,14 +1499,14 @@ def download_log(logtype):
|
||||
|
||||
|
||||
@admi.route("/admin/debug")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def download_debug():
|
||||
return debug_info.send_debug()
|
||||
|
||||
|
||||
@admi.route("/get_update_status", methods=['GET'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def get_update_status():
|
||||
if feature_support['updater']:
|
||||
@ -1516,7 +1517,7 @@ def get_update_status():
|
||||
|
||||
|
||||
@admi.route("/get_updater_status", methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def get_updater_status():
|
||||
status = {}
|
||||
@ -1611,7 +1612,7 @@ def ldap_import_create_user(user, user_data):
|
||||
|
||||
|
||||
@admi.route('/import_ldap_users', methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def import_ldap_users():
|
||||
showtext = {}
|
||||
@ -1666,7 +1667,7 @@ def import_ldap_users():
|
||||
|
||||
|
||||
@admi.route("/ajax/canceltask", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def cancel_task():
|
||||
task_id = request.get_json().get('task_id', None)
|
||||
@ -1678,10 +1679,10 @@ def cancel_task():
|
||||
def _db_simulate_change():
|
||||
param = request.form.to_dict()
|
||||
to_save = dict()
|
||||
to_save['config_calibre_dir'] = re.sub(r'[\\/]metadata\.db$',
|
||||
to_save['config_calibre_dir'] = strip_whitespaces(re.sub(r'[\\/]metadata\.db$',
|
||||
'',
|
||||
param['config_calibre_dir'],
|
||||
flags=re.IGNORECASE).strip()
|
||||
flags=re.IGNORECASE))
|
||||
db_valid, db_change = calibre_db.check_valid_db(to_save["config_calibre_dir"],
|
||||
ub.app_DB_path,
|
||||
config.config_calibre_uuid)
|
||||
@ -1775,9 +1776,8 @@ def _configuration_update_helper():
|
||||
|
||||
if "config_upload_formats" in to_save:
|
||||
to_save["config_upload_formats"] = ','.join(
|
||||
helper.uniq([x.lstrip().rstrip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
||||
helper.uniq([x.strip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
||||
_config_string(to_save, "config_upload_formats")
|
||||
# constants.EXTENSIONS_UPLOAD = config.config_upload_formats.split(',')
|
||||
|
||||
_config_string(to_save, "config_calibre")
|
||||
_config_string(to_save, "config_binariesdir")
|
||||
|
152
cps/audio.py
Normal file
152
cps/audio.py
Normal file
@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
|
||||
import mutagen
|
||||
import base64
|
||||
from . import cover, logger
|
||||
|
||||
from cps.constants import BookMeta
|
||||
|
||||
log = logger.create()
|
||||
|
||||
def get_audio_file_info(tmp_file_path, original_file_extension, original_file_name):
|
||||
tmp_cover_name = None
|
||||
audio_file = mutagen.File(tmp_file_path)
|
||||
comments = None
|
||||
if original_file_extension in [".mp3", ".wav", ".aiff"]:
|
||||
cover_data = list()
|
||||
for key, val in audio_file.tags.items():
|
||||
if key.startswith("APIC:"):
|
||||
cover_data.append(val)
|
||||
if key.startswith("COMM:"):
|
||||
comments = val.text[0]
|
||||
title = audio_file.tags.get('TIT2').text[0] if "TIT2" in audio_file.tags else None
|
||||
author = audio_file.tags.get('TPE1').text[0] if "TPE1" in audio_file.tags else None
|
||||
if author is None:
|
||||
author = audio_file.tags.get('TPE2').text[0] if "TPE2" in audio_file.tags else None
|
||||
tags = audio_file.tags.get('TCON').text[0] if "TCON" in audio_file.tags else None # Genre
|
||||
series = audio_file.tags.get('TALB').text[0] if "TALB" in audio_file.tags else None# Album
|
||||
series_id = audio_file.tags.get('TRCK').text[0] if "TRCK" in audio_file.tags else None # track no.
|
||||
publisher = audio_file.tags.get('TPUB').text[0] if "TPUB" in audio_file.tags else None
|
||||
pubdate = str(audio_file.tags.get('TDRL').text[0]) if "TDRL" in audio_file.tags else None
|
||||
if not pubdate:
|
||||
pubdate = str(audio_file.tags.get('TDRC').text[0]) if "TDRC" in audio_file.tags else None
|
||||
if not pubdate:
|
||||
pubdate = str(audio_file.tags.get('TDOR').text[0]) if "TDOR" in audio_file.tags else None
|
||||
if cover_data:
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||
cover_info = cover_data[0]
|
||||
for dat in cover_data:
|
||||
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
||||
cover_info = dat
|
||||
break
|
||||
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||
elif original_file_extension in [".ogg", ".flac", ".opus", ".ogv"]:
|
||||
title = audio_file.tags.get('TITLE')[0] if "TITLE" in audio_file else None
|
||||
author = audio_file.tags.get('ARTIST')[0] if "ARTIST" in audio_file else None
|
||||
comments = audio_file.tags.get('COMMENTS')[0] if "COMMENTS" in audio_file else None
|
||||
tags = audio_file.tags.get('GENRE')[0] if "GENRE" in audio_file else None # Genre
|
||||
series = audio_file.tags.get('ALBUM')[0] if "ALBUM" in audio_file else None
|
||||
series_id = audio_file.tags.get('TRACKNUMBER')[0] if "TRACKNUMBER" in audio_file else None
|
||||
publisher = audio_file.tags.get('LABEL')[0] if "LABEL" in audio_file else None
|
||||
pubdate = audio_file.tags.get('DATE')[0] if "DATE" in audio_file else None
|
||||
cover_data = audio_file.tags.get('METADATA_BLOCK_PICTURE')
|
||||
if cover_data:
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||
cover_info = mutagen.flac.Picture(base64.b64decode(cover_data[0]))
|
||||
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||
if hasattr(audio_file, "pictures"):
|
||||
cover_info = audio_file.pictures[0]
|
||||
for dat in audio_file.pictures:
|
||||
if dat.type == mutagen.id3.PictureType.COVER_FRONT:
|
||||
cover_info = dat
|
||||
break
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||
cover.cover_processing(tmp_file_path, cover_info.data, "." + cover_info.mime[-3:])
|
||||
elif original_file_extension in [".aac"]:
|
||||
title = audio_file.tags.get('Title').value if "Title" in audio_file else None
|
||||
author = audio_file.tags.get('Artist').value if "Artist" in audio_file else None
|
||||
comments = audio_file.tags.get('Comment').value if "Comment" in audio_file else None
|
||||
tags = audio_file.tags.get('Genre').value if "Genre" in audio_file else None
|
||||
series = audio_file.tags.get('Album').value if "Album" in audio_file else None
|
||||
series_id = audio_file.tags.get('Track').value if "Track" in audio_file else None
|
||||
publisher = audio_file.tags.get('Label').value if "Label" in audio_file else None
|
||||
pubdate = audio_file.tags.get('Year').value if "Year" in audio_file else None
|
||||
cover_data = audio_file.tags['Cover Art (Front)']
|
||||
if cover_data:
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||
with open(tmp_cover_name, "wb") as cover_file:
|
||||
cover_file.write(cover_data.value.split(b"\x00",1)[1])
|
||||
elif original_file_extension in [".asf"]:
|
||||
title = audio_file.tags.get('Title')[0].value if "Title" in audio_file else None
|
||||
author = audio_file.tags.get('Artist')[0].value if "Artist" in audio_file else None
|
||||
comments = audio_file.tags.get('Comments')[0].value if "Comments" in audio_file else None
|
||||
tags = audio_file.tags.get('Genre')[0].value if "Genre" in audio_file else None
|
||||
series = audio_file.tags.get('Album')[0].value if "Album" in audio_file else None
|
||||
series_id = audio_file.tags.get('Track')[0].value if "Track" in audio_file else None
|
||||
publisher = audio_file.tags.get('Label')[0].value if "Label" in audio_file else None
|
||||
pubdate = audio_file.tags.get('Year')[0].value if "Year" in audio_file else None
|
||||
cover_data = audio_file.tags.get('WM/Picture', None)
|
||||
if cover_data:
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||
with open(tmp_cover_name, "wb") as cover_file:
|
||||
cover_file.write(cover_data[0].value)
|
||||
elif original_file_extension in [".mp4", ".m4a", ".m4b"]:
|
||||
title = audio_file.tags.get('©nam')[0] if "©nam" in audio_file.tags else None
|
||||
author = audio_file.tags.get('©ART')[0] if "©ART" in audio_file.tags else None
|
||||
comments = audio_file.tags.get('©cmt')[0] if "©cmt" in audio_file.tags else None
|
||||
tags = audio_file.tags.get('©gen')[0] if "©gen" in audio_file.tags else None
|
||||
series = audio_file.tags.get('©alb')[0] if "©alb" in audio_file.tags else None
|
||||
series_id = str(audio_file.tags.get('trkn')[0][0]) if "trkn" in audio_file.tags else None
|
||||
publisher = ""
|
||||
pubdate = audio_file.tags.get('©day')[0] if "©day" in audio_file.tags else None
|
||||
cover_data = audio_file.tags.get('covr', None)
|
||||
if cover_data:
|
||||
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_path), 'cover.jpg')
|
||||
cover_type = None
|
||||
for c in cover_data:
|
||||
if c.imageformat == mutagen.mp4.AtomDataType.JPEG:
|
||||
cover_type =".jpg"
|
||||
cover_bin = c
|
||||
break
|
||||
elif c.imageformat == mutagen.mp4.AtomDataType.PNG:
|
||||
cover_type = ".png"
|
||||
cover_bin = c
|
||||
break
|
||||
if cover_type:
|
||||
cover.cover_processing(tmp_file_path, cover_bin, cover_type)
|
||||
else:
|
||||
logger.error("Unknown covertype in file {} ".format(original_file_name))
|
||||
|
||||
return BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
extension=original_file_extension,
|
||||
title=title or original_file_name ,
|
||||
author="Unknown" if author is None else author,
|
||||
cover=tmp_cover_name,
|
||||
description="" if comments is None else comments,
|
||||
tags="" if tags is None else tags,
|
||||
series="" if series is None else series,
|
||||
series_id="1" if series_id is None else series_id.split("/")[0],
|
||||
languages="",
|
||||
publisher= "" if publisher is None else publisher,
|
||||
pubdate="" if pubdate is None else pubdate,
|
||||
identifiers=[],
|
||||
)
|
@ -2,7 +2,7 @@ from babel import negotiate_locale
|
||||
from flask_babel import Babel, Locale
|
||||
from babel.core import UnknownLocaleError
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
|
||||
from . import logger
|
||||
|
||||
|
@ -35,7 +35,7 @@ except ImportError:
|
||||
|
||||
from . import constants, logger
|
||||
from .subproc_wrapper import process_wait
|
||||
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
_Base = declarative_base()
|
||||
@ -288,19 +288,19 @@ class ConfigSQL(object):
|
||||
|
||||
def list_denied_tags(self):
|
||||
mct = self.config_denied_tags or ""
|
||||
return [t.strip() for t in mct.split(",")]
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_allowed_tags(self):
|
||||
mct = self.config_allowed_tags or ""
|
||||
return [t.strip() for t in mct.split(",")]
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_denied_column_values(self):
|
||||
mct = self.config_denied_column_value or ""
|
||||
return [t.strip() for t in mct.split(",")]
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def list_allowed_column_values(self):
|
||||
mct = self.config_allowed_column_value or ""
|
||||
return [t.strip() for t in mct.split(",")]
|
||||
return [strip_whitespaces(t) for t in mct.split(",")]
|
||||
|
||||
def get_log_level(self):
|
||||
return logger.get_level_name(self.config_log_level)
|
||||
@ -372,7 +372,7 @@ class ConfigSQL(object):
|
||||
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
||||
have_metadata_db = os.path.isfile(db_file)
|
||||
self.db_configured = have_metadata_db
|
||||
# constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||
|
||||
from . import cli_param
|
||||
if os.environ.get('FLASK_DEBUG'):
|
||||
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
||||
|
@ -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 = {'version': '0.6.23b'}
|
||||
STABLE_VERSION = {'version': '0.6.24b'}
|
||||
|
||||
NIGHTLY_VERSION = dict()
|
||||
NIGHTLY_VERSION[0] = '$Format:%H$'
|
||||
@ -193,7 +193,7 @@ THUMBNAIL_TYPE_AUTHOR = 3
|
||||
COVER_THUMBNAIL_ORIGINAL = 0
|
||||
COVER_THUMBNAIL_SMALL = 1
|
||||
COVER_THUMBNAIL_MEDIUM = 2
|
||||
COVER_THUMBNAIL_LARGE = 3
|
||||
COVER_THUMBNAIL_LARGE = 4
|
||||
|
||||
# clean-up the module namespace
|
||||
del sys, os, namedtuple
|
||||
|
98
cps/cw_login/__init__.py
Normal file
98
cps/cw_login/__init__.py
Normal file
@ -0,0 +1,98 @@
|
||||
# from .__about__ import __version__
|
||||
from .config import AUTH_HEADER_NAME
|
||||
from .config import COOKIE_DURATION
|
||||
from .config import COOKIE_HTTPONLY
|
||||
from .config import COOKIE_NAME
|
||||
from .config import COOKIE_SECURE
|
||||
from .config import ID_ATTRIBUTE
|
||||
from .config import LOGIN_MESSAGE
|
||||
from .config import LOGIN_MESSAGE_CATEGORY
|
||||
from .config import REFRESH_MESSAGE
|
||||
from .config import REFRESH_MESSAGE_CATEGORY
|
||||
from .login_manager import LoginManager
|
||||
from .mixins import AnonymousUserMixin
|
||||
from .mixins import UserMixin
|
||||
from .signals import session_protected
|
||||
from .signals import user_accessed
|
||||
from .signals import user_loaded_from_cookie
|
||||
from .signals import user_loaded_from_request
|
||||
from .signals import user_logged_in
|
||||
from .signals import user_logged_out
|
||||
from .signals import user_login_confirmed
|
||||
from .signals import user_needs_refresh
|
||||
from .signals import user_unauthorized
|
||||
# from .test_client import FlaskLoginClient
|
||||
from .utils import confirm_login
|
||||
from .utils import current_user
|
||||
from .utils import decode_cookie
|
||||
from .utils import encode_cookie
|
||||
from .utils import fresh_login_required
|
||||
from .utils import login_fresh
|
||||
from .utils import login_remembered
|
||||
from .utils import login_required
|
||||
from .utils import login_url
|
||||
from .utils import login_user
|
||||
from .utils import logout_user
|
||||
from .utils import make_next_param
|
||||
from .utils import set_login_view
|
||||
|
||||
__version_info__ = ("0", "6", "3")
|
||||
__version__ = ".".join(__version_info__)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AUTH_HEADER_NAME",
|
||||
"COOKIE_DURATION",
|
||||
"COOKIE_HTTPONLY",
|
||||
"COOKIE_NAME",
|
||||
"COOKIE_SECURE",
|
||||
"ID_ATTRIBUTE",
|
||||
"LOGIN_MESSAGE",
|
||||
"LOGIN_MESSAGE_CATEGORY",
|
||||
"REFRESH_MESSAGE",
|
||||
"REFRESH_MESSAGE_CATEGORY",
|
||||
"LoginManager",
|
||||
"AnonymousUserMixin",
|
||||
"UserMixin",
|
||||
"session_protected",
|
||||
"user_accessed",
|
||||
"user_loaded_from_cookie",
|
||||
"user_loaded_from_request",
|
||||
"user_logged_in",
|
||||
"user_logged_out",
|
||||
"user_login_confirmed",
|
||||
"user_needs_refresh",
|
||||
"user_unauthorized",
|
||||
# "FlaskLoginClient",
|
||||
"confirm_login",
|
||||
"current_user",
|
||||
"decode_cookie",
|
||||
"encode_cookie",
|
||||
"fresh_login_required",
|
||||
"login_fresh",
|
||||
"login_remembered",
|
||||
"login_required",
|
||||
"login_url",
|
||||
"login_user",
|
||||
"logout_user",
|
||||
"make_next_param",
|
||||
"set_login_view",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name == "user_loaded_from_header":
|
||||
import warnings
|
||||
from .signals import _user_loaded_from_header
|
||||
|
||||
warnings.warn(
|
||||
"'user_loaded_from_header' is deprecated and will be"
|
||||
" removed in Flask-Login 0.7. Use"
|
||||
" 'user_loaded_from_request' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return _user_loaded_from_header
|
||||
|
||||
raise AttributeError(name)
|
55
cps/cw_login/config.py
Normal file
55
cps/cw_login/config.py
Normal file
@ -0,0 +1,55 @@
|
||||
from datetime import timedelta
|
||||
|
||||
#: The default name of the "remember me" cookie (``remember_token``)
|
||||
COOKIE_NAME = "remember_token"
|
||||
|
||||
#: The default time before the "remember me" cookie expires (365 days).
|
||||
COOKIE_DURATION = timedelta(days=365)
|
||||
|
||||
#: Whether the "remember me" cookie requires Secure; defaults to ``False``
|
||||
COOKIE_SECURE = False
|
||||
|
||||
#: Whether the "remember me" cookie uses HttpOnly or not; defaults to ``True``
|
||||
COOKIE_HTTPONLY = True
|
||||
|
||||
#: Whether the "remember me" cookie requires same origin; defaults to ``None``
|
||||
COOKIE_SAMESITE = None
|
||||
|
||||
#: The default flash message to display when users need to log in.
|
||||
LOGIN_MESSAGE = "Please log in to access this page."
|
||||
|
||||
#: The default flash message category to display when users need to log in.
|
||||
LOGIN_MESSAGE_CATEGORY = "message"
|
||||
|
||||
#: The default flash message to display when users need to reauthenticate.
|
||||
REFRESH_MESSAGE = "Please reauthenticate to access this page."
|
||||
|
||||
#: The default flash message category to display when users need to
|
||||
#: reauthenticate.
|
||||
REFRESH_MESSAGE_CATEGORY = "message"
|
||||
|
||||
#: The default attribute to retreive the str id of the user
|
||||
ID_ATTRIBUTE = "get_id"
|
||||
|
||||
#: Default name of the auth header (``Authorization``)
|
||||
AUTH_HEADER_NAME = "Authorization"
|
||||
|
||||
#: A set of session keys that are populated by Flask-Login. Use this set to
|
||||
#: purge keys safely and accurately.
|
||||
SESSION_KEYS = {
|
||||
"_user_id",
|
||||
"_remember",
|
||||
"_remember_seconds",
|
||||
"_id",
|
||||
"_fresh",
|
||||
"next",
|
||||
}
|
||||
|
||||
#: A set of HTTP methods which are exempt from `login_required` and
|
||||
#: `fresh_login_required`. By default, this is just ``OPTIONS``.
|
||||
EXEMPT_METHODS = {"OPTIONS"}
|
||||
|
||||
#: If true, the page the user is attempting to access is stored in the session
|
||||
#: rather than a url parameter when redirecting to the login view; defaults to
|
||||
#: ``False``.
|
||||
USE_SESSION_FOR_NEXT = False
|
555
cps/cw_login/login_manager.py
Normal file
555
cps/cw_login/login_manager.py
Normal file
@ -0,0 +1,555 @@
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
|
||||
from flask import abort
|
||||
from flask import current_app
|
||||
from flask import flash
|
||||
from flask import g
|
||||
from flask import has_app_context
|
||||
from flask import redirect
|
||||
from flask import request
|
||||
from flask import session
|
||||
from itsdangerous import URLSafeSerializer
|
||||
from flask.json.tag import TaggedJSONSerializer
|
||||
|
||||
from .config import AUTH_HEADER_NAME
|
||||
from .config import COOKIE_DURATION
|
||||
from .config import COOKIE_HTTPONLY
|
||||
from .config import COOKIE_NAME
|
||||
from .config import COOKIE_SAMESITE
|
||||
from .config import COOKIE_SECURE
|
||||
from .config import ID_ATTRIBUTE
|
||||
from .config import LOGIN_MESSAGE
|
||||
from .config import LOGIN_MESSAGE_CATEGORY
|
||||
from .config import REFRESH_MESSAGE
|
||||
from .config import REFRESH_MESSAGE_CATEGORY
|
||||
from .config import SESSION_KEYS
|
||||
from .config import USE_SESSION_FOR_NEXT
|
||||
from .mixins import AnonymousUserMixin
|
||||
from .signals import session_protected
|
||||
from .signals import user_accessed
|
||||
from .signals import user_loaded_from_cookie
|
||||
from .signals import user_loaded_from_request
|
||||
from .signals import user_needs_refresh
|
||||
from .signals import user_unauthorized
|
||||
from .utils import _create_identifier
|
||||
from .utils import _user_context_processor
|
||||
from .utils import confirm_login
|
||||
from .utils import expand_login_view
|
||||
from .utils import login_url as make_login_url
|
||||
from .utils import make_next_param
|
||||
|
||||
|
||||
class LoginManager:
|
||||
"""This object is used to hold the settings used for logging in. Instances
|
||||
of :class:`LoginManager` are *not* bound to specific apps, so you can
|
||||
create one in the main body of your code and then bind it to your
|
||||
app in a factory function.
|
||||
"""
|
||||
|
||||
def __init__(self, app=None, add_context_processor=True):
|
||||
#: A class or factory function that produces an anonymous user, which
|
||||
#: is used when no one is logged in.
|
||||
self.anonymous_user = AnonymousUserMixin
|
||||
|
||||
#: The name of the view to redirect to when the user needs to log in.
|
||||
#: (This can be an absolute URL as well, if your authentication
|
||||
#: machinery is external to your application.)
|
||||
self.login_view = None
|
||||
|
||||
#: Names of views to redirect to when the user needs to log in,
|
||||
#: per blueprint. If the key value is set to None the value of
|
||||
#: :attr:`login_view` will be used instead.
|
||||
self.blueprint_login_views = {}
|
||||
|
||||
#: The message to flash when a user is redirected to the login page.
|
||||
self.login_message = LOGIN_MESSAGE
|
||||
|
||||
#: The message category to flash when a user is redirected to the login
|
||||
#: page.
|
||||
self.login_message_category = LOGIN_MESSAGE_CATEGORY
|
||||
|
||||
#: The name of the view to redirect to when the user needs to
|
||||
#: reauthenticate.
|
||||
self.refresh_view = None
|
||||
|
||||
#: The message to flash when a user is redirected to the 'needs
|
||||
#: refresh' page.
|
||||
self.needs_refresh_message = REFRESH_MESSAGE
|
||||
|
||||
#: The message category to flash when a user is redirected to the
|
||||
#: 'needs refresh' page.
|
||||
self.needs_refresh_message_category = REFRESH_MESSAGE_CATEGORY
|
||||
|
||||
#: The mode to use session protection in. This can be either
|
||||
#: ``'basic'`` (the default) or ``'strong'``, or ``None`` to disable
|
||||
#: it.
|
||||
self.session_protection = "basic"
|
||||
|
||||
#: If present, used to translate flash messages ``self.login_message``
|
||||
#: and ``self.needs_refresh_message``
|
||||
self.localize_callback = None
|
||||
|
||||
self.unauthorized_callback = None
|
||||
|
||||
self.needs_refresh_callback = None
|
||||
|
||||
self.id_attribute = ID_ATTRIBUTE
|
||||
|
||||
self._user_callback = None
|
||||
|
||||
self._header_callback = None
|
||||
|
||||
self._request_callback = None
|
||||
|
||||
self._session_identifier_generator = _create_identifier
|
||||
|
||||
if app is not None:
|
||||
self.init_app(app, add_context_processor)
|
||||
|
||||
def setup_app(self, app, add_context_processor=True): # pragma: no cover
|
||||
"""
|
||||
This method has been deprecated. Please use
|
||||
:meth:`LoginManager.init_app` instead.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"'setup_app' is deprecated and will be removed in"
|
||||
" Flask-Login 0.7. Use 'init_app' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self.init_app(app, add_context_processor)
|
||||
|
||||
def init_app(self, app, add_context_processor=True):
|
||||
"""
|
||||
Configures an application. This registers an `after_request` call, and
|
||||
attaches this `LoginManager` to it as `app.login_manager`.
|
||||
|
||||
:param app: The :class:`flask.Flask` object to configure.
|
||||
:type app: :class:`flask.Flask`
|
||||
:param add_context_processor: Whether to add a context processor to
|
||||
the app that adds a `current_user` variable to the template.
|
||||
Defaults to ``True``.
|
||||
:type add_context_processor: bool
|
||||
"""
|
||||
app.login_manager = self
|
||||
app.after_request(self._update_remember_cookie)
|
||||
|
||||
if add_context_processor:
|
||||
app.context_processor(_user_context_processor)
|
||||
|
||||
def unauthorized(self):
|
||||
"""
|
||||
This is called when the user is required to log in. If you register a
|
||||
callback with :meth:`LoginManager.unauthorized_handler`, then it will
|
||||
be called. Otherwise, it will take the following actions:
|
||||
|
||||
- Flash :attr:`LoginManager.login_message` to the user.
|
||||
|
||||
- If the app is using blueprints find the login view for
|
||||
the current blueprint using `blueprint_login_views`. If the app
|
||||
is not using blueprints or the login view for the current
|
||||
blueprint is not specified use the value of `login_view`.
|
||||
|
||||
- Redirect the user to the login view. (The page they were
|
||||
attempting to access will be passed in the ``next`` query
|
||||
string variable, so you can redirect there if present instead
|
||||
of the homepage. Alternatively, it will be added to the session
|
||||
as ``next`` if USE_SESSION_FOR_NEXT is set.)
|
||||
|
||||
If :attr:`LoginManager.login_view` is not defined, then it will simply
|
||||
raise a HTTP 401 (Unauthorized) error instead.
|
||||
|
||||
This should be returned from a view or before/after_request function,
|
||||
otherwise the redirect will have no effect.
|
||||
"""
|
||||
user_unauthorized.send(current_app._get_current_object())
|
||||
|
||||
if self.unauthorized_callback:
|
||||
return self.unauthorized_callback()
|
||||
|
||||
if request.blueprint in self.blueprint_login_views:
|
||||
login_view = self.blueprint_login_views[request.blueprint]
|
||||
else:
|
||||
login_view = self.login_view
|
||||
|
||||
if not login_view:
|
||||
abort(401)
|
||||
|
||||
if self.login_message:
|
||||
if self.localize_callback is not None:
|
||||
flash(
|
||||
self.localize_callback(self.login_message),
|
||||
category=self.login_message_category,
|
||||
)
|
||||
else:
|
||||
flash(self.login_message, category=self.login_message_category)
|
||||
|
||||
config = current_app.config
|
||||
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
|
||||
login_url = expand_login_view(login_view)
|
||||
session["_id"] = self._session_identifier_generator()
|
||||
session["next"] = make_next_param(login_url, request.url)
|
||||
redirect_url = make_login_url(login_view)
|
||||
else:
|
||||
redirect_url = make_login_url(login_view, next_url=request.url)
|
||||
|
||||
return redirect(redirect_url)
|
||||
|
||||
def user_loader(self, callback):
|
||||
"""
|
||||
This sets the callback for reloading a user from the session. The
|
||||
function you set should take a user ID (a ``str``) and return a
|
||||
user object, or ``None`` if the user does not exist.
|
||||
|
||||
:param callback: The callback for retrieving a user object.
|
||||
:type callback: callable
|
||||
"""
|
||||
self._user_callback = callback
|
||||
return self.user_callback
|
||||
|
||||
@property
|
||||
def user_callback(self):
|
||||
"""Gets the user_loader callback set by user_loader decorator."""
|
||||
return self._user_callback
|
||||
|
||||
def request_loader(self, callback):
|
||||
"""
|
||||
This sets the callback for loading a user from a Flask request.
|
||||
The function you set should take Flask request object and
|
||||
return a user object, or `None` if the user does not exist.
|
||||
|
||||
:param callback: The callback for retrieving a user object.
|
||||
:type callback: callable
|
||||
"""
|
||||
self._request_callback = callback
|
||||
return self.request_callback
|
||||
|
||||
@property
|
||||
def request_callback(self):
|
||||
"""Gets the request_loader callback set by request_loader decorator."""
|
||||
return self._request_callback
|
||||
|
||||
def unauthorized_handler(self, callback):
|
||||
"""
|
||||
This will set the callback for the `unauthorized` method, which among
|
||||
other things is used by `login_required`. It takes no arguments, and
|
||||
should return a response to be sent to the user instead of their
|
||||
normal view.
|
||||
|
||||
:param callback: The callback for unauthorized users.
|
||||
:type callback: callable
|
||||
"""
|
||||
self.unauthorized_callback = callback
|
||||
return callback
|
||||
|
||||
def needs_refresh_handler(self, callback):
|
||||
"""
|
||||
This will set the callback for the `needs_refresh` method, which among
|
||||
other things is used by `fresh_login_required`. It takes no arguments,
|
||||
and should return a response to be sent to the user instead of their
|
||||
normal view.
|
||||
|
||||
:param callback: The callback for unauthorized users.
|
||||
:type callback: callable
|
||||
"""
|
||||
self.needs_refresh_callback = callback
|
||||
return callback
|
||||
|
||||
def needs_refresh(self):
|
||||
"""
|
||||
This is called when the user is logged in, but they need to be
|
||||
reauthenticated because their session is stale. If you register a
|
||||
callback with `needs_refresh_handler`, then it will be called.
|
||||
Otherwise, it will take the following actions:
|
||||
|
||||
- Flash :attr:`LoginManager.needs_refresh_message` to the user.
|
||||
|
||||
- Redirect the user to :attr:`LoginManager.refresh_view`. (The page
|
||||
they were attempting to access will be passed in the ``next``
|
||||
query string variable, so you can redirect there if present
|
||||
instead of the homepage.)
|
||||
|
||||
If :attr:`LoginManager.refresh_view` is not defined, then it will
|
||||
simply raise a HTTP 401 (Unauthorized) error instead.
|
||||
|
||||
This should be returned from a view or before/after_request function,
|
||||
otherwise the redirect will have no effect.
|
||||
"""
|
||||
user_needs_refresh.send(current_app._get_current_object())
|
||||
|
||||
if self.needs_refresh_callback:
|
||||
return self.needs_refresh_callback()
|
||||
|
||||
if not self.refresh_view:
|
||||
abort(401)
|
||||
|
||||
if self.needs_refresh_message:
|
||||
if self.localize_callback is not None:
|
||||
flash(
|
||||
self.localize_callback(self.needs_refresh_message),
|
||||
category=self.needs_refresh_message_category,
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
self.needs_refresh_message,
|
||||
category=self.needs_refresh_message_category,
|
||||
)
|
||||
|
||||
config = current_app.config
|
||||
if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
|
||||
login_url = expand_login_view(self.refresh_view)
|
||||
session["_id"] = self._session_identifier_generator()
|
||||
session["next"] = make_next_param(login_url, request.url)
|
||||
redirect_url = make_login_url(self.refresh_view)
|
||||
else:
|
||||
login_url = self.refresh_view
|
||||
redirect_url = make_login_url(login_url, next_url=request.url)
|
||||
|
||||
return redirect(redirect_url)
|
||||
|
||||
def _update_request_context_with_user(self, user=None):
|
||||
"""Store the given user as ctx.user."""
|
||||
|
||||
if user is None:
|
||||
user = self.anonymous_user()
|
||||
|
||||
g._login_user = user
|
||||
|
||||
def _load_user(self):
|
||||
"""Loads user from session or remember_me cookie as applicable"""
|
||||
|
||||
if self._user_callback is None and self._request_callback is None:
|
||||
raise Exception(
|
||||
"Missing user_loader or request_loader. Refer to "
|
||||
"https://flask-login.readthedocs.io/#how-it-works "
|
||||
"for more info."
|
||||
)
|
||||
|
||||
user_accessed.send(current_app._get_current_object())
|
||||
|
||||
# Check SESSION_PROTECTION
|
||||
if self._session_protection_failed():
|
||||
return self._update_request_context_with_user()
|
||||
|
||||
user = None
|
||||
|
||||
# Load user from Flask Session
|
||||
user_id = session.get("_user_id")
|
||||
user_random = session.get("_random")
|
||||
user_session_key = session.get("_id")
|
||||
if (user_id is not None
|
||||
and user_random is not None
|
||||
and user_session_key is not None
|
||||
and self._user_callback is not None):
|
||||
user = self._user_callback(user_id, user_random, user_session_key)
|
||||
|
||||
# Load user from Remember Me Cookie or Request Loader
|
||||
if user is None:
|
||||
config = current_app.config
|
||||
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||
header_name = config.get("AUTH_HEADER_NAME", AUTH_HEADER_NAME)
|
||||
has_cookie = (
|
||||
cookie_name in request.cookies and session.get("_remember") != "clear"
|
||||
)
|
||||
if has_cookie:
|
||||
cookie = request.cookies[cookie_name]
|
||||
user = self._load_user_from_remember_cookie(cookie)
|
||||
elif self._request_callback:
|
||||
user = self._load_user_from_request(request)
|
||||
elif header_name in request.headers:
|
||||
header = request.headers[header_name]
|
||||
user = self._load_user_from_header(header)
|
||||
if not user:
|
||||
self._update_request_context_with_user()
|
||||
return self._update_request_context_with_user(user)
|
||||
|
||||
def _session_protection_failed(self):
|
||||
sess = session._get_current_object()
|
||||
ident = self._session_identifier_generator()
|
||||
|
||||
app = current_app._get_current_object()
|
||||
mode = app.config.get("SESSION_PROTECTION", self.session_protection)
|
||||
|
||||
if not mode or mode not in ["basic", "strong"]:
|
||||
return False
|
||||
|
||||
# if the sess is empty, it's an anonymous user or just logged out
|
||||
# so we can skip this
|
||||
if sess and ident != sess.get("_id", None):
|
||||
if mode == "basic" or sess.permanent:
|
||||
if sess.get("_fresh") is not False:
|
||||
sess["_fresh"] = False
|
||||
session_protected.send(app)
|
||||
return False
|
||||
elif mode == "strong":
|
||||
for k in SESSION_KEYS:
|
||||
sess.pop(k, None)
|
||||
|
||||
sess["_remember"] = "clear"
|
||||
session_protected.send(app)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _load_user_from_remember_cookie(self, cookie):
|
||||
signer_kwargs = dict(
|
||||
key_derivation="hmac", digest_method=hashlib.sha1
|
||||
)
|
||||
try:
|
||||
remember_dict = URLSafeSerializer(
|
||||
current_app.secret_key,
|
||||
salt="remember",
|
||||
serializer=TaggedJSONSerializer(),
|
||||
signer_kwargs=signer_kwargs,
|
||||
).loads(cookie)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if remember_dict['user'] is not None:
|
||||
session["_user_id"] = remember_dict['user']
|
||||
if "_random" not in session:
|
||||
session["_random"] = remember_dict['random']
|
||||
session["_fresh"] = False
|
||||
user = None
|
||||
if self._user_callback:
|
||||
user = self._user_callback(remember_dict['user'], session["_random"], None)
|
||||
if user is not None:
|
||||
app = current_app._get_current_object()
|
||||
user_loaded_from_cookie.send(app, user=user)
|
||||
# if session was restored from remember me cookie make login valid
|
||||
confirm_login()
|
||||
return user
|
||||
return None
|
||||
|
||||
def _load_user_from_header(self, header):
|
||||
if self._header_callback:
|
||||
user = self._header_callback(header)
|
||||
if user is not None:
|
||||
app = current_app._get_current_object()
|
||||
|
||||
from .signals import _user_loaded_from_header
|
||||
|
||||
_user_loaded_from_header.send(app, user=user)
|
||||
return user
|
||||
return None
|
||||
|
||||
def _load_user_from_request(self, request):
|
||||
if self._request_callback:
|
||||
user = self._request_callback(request)
|
||||
if user is not None:
|
||||
app = current_app._get_current_object()
|
||||
user_loaded_from_request.send(app, user=user)
|
||||
return user
|
||||
return None
|
||||
|
||||
def _update_remember_cookie(self, response):
|
||||
# Don't modify the session unless there's something to do.
|
||||
if "_remember" not in session and current_app.config.get(
|
||||
"REMEMBER_COOKIE_REFRESH_EACH_REQUEST"
|
||||
):
|
||||
session["_remember"] = "set"
|
||||
|
||||
if "_remember" in session:
|
||||
operation = session.pop("_remember", None)
|
||||
|
||||
if operation == "set" and "_user_id" in session:
|
||||
self._set_cookie(response)
|
||||
elif operation == "clear":
|
||||
self._clear_cookie(response)
|
||||
|
||||
return response
|
||||
|
||||
def _set_cookie(self, response):
|
||||
# cookie settings
|
||||
config = current_app.config
|
||||
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
||||
path = config.get("REMEMBER_COOKIE_PATH", "/")
|
||||
|
||||
secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
|
||||
httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
|
||||
samesite = config.get("REMEMBER_COOKIE_SAMESITE", COOKIE_SAMESITE)
|
||||
|
||||
if "_remember_seconds" in session:
|
||||
duration = timedelta(seconds=session["_remember_seconds"])
|
||||
else:
|
||||
duration = config.get("REMEMBER_COOKIE_DURATION", COOKIE_DURATION)
|
||||
|
||||
# prepare data
|
||||
max_age = int(current_app.permanent_session_lifetime.total_seconds())
|
||||
signer_kwargs = dict(
|
||||
key_derivation="hmac", digest_method=hashlib.sha1
|
||||
)
|
||||
# save
|
||||
data = URLSafeSerializer(
|
||||
current_app.secret_key,
|
||||
salt="remember",
|
||||
serializer=TaggedJSONSerializer(),
|
||||
signer_kwargs=signer_kwargs,
|
||||
).dumps({"user":session["_user_id"], "random":session["_random"]})
|
||||
|
||||
if isinstance(duration, int):
|
||||
duration = timedelta(seconds=duration)
|
||||
|
||||
try:
|
||||
expires = datetime.now(timezone.utc) + duration
|
||||
except TypeError as e:
|
||||
raise Exception(
|
||||
"REMEMBER_COOKIE_DURATION must be a datetime.timedelta,"
|
||||
f" instead got: {duration}"
|
||||
) from e
|
||||
|
||||
# actually set it
|
||||
response.set_cookie(
|
||||
cookie_name,
|
||||
value=data,
|
||||
expires=expires,
|
||||
domain=domain,
|
||||
path=path,
|
||||
secure=secure,
|
||||
httponly=httponly,
|
||||
samesite=samesite,
|
||||
)
|
||||
|
||||
def _clear_cookie(self, response):
|
||||
config = current_app.config
|
||||
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||
domain = config.get("REMEMBER_COOKIE_DOMAIN")
|
||||
path = config.get("REMEMBER_COOKIE_PATH", "/")
|
||||
response.delete_cookie(cookie_name, domain=domain, path=path)
|
||||
|
||||
@property
|
||||
def _login_disabled(self):
|
||||
"""Legacy property, use app.config['LOGIN_DISABLED'] instead."""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"'_login_disabled' is deprecated and will be removed in"
|
||||
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
|
||||
" instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if has_app_context():
|
||||
return current_app.config.get("LOGIN_DISABLED", False)
|
||||
return False
|
||||
|
||||
@_login_disabled.setter
|
||||
def _login_disabled(self, newvalue):
|
||||
"""Legacy property setter, use app.config['LOGIN_DISABLED'] instead."""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"'_login_disabled' is deprecated and will be removed in"
|
||||
" Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
|
||||
" instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
current_app.config["LOGIN_DISABLED"] = newvalue
|
65
cps/cw_login/mixins.py
Normal file
65
cps/cw_login/mixins.py
Normal file
@ -0,0 +1,65 @@
|
||||
class UserMixin:
|
||||
"""
|
||||
This provides default implementations for the methods that Flask-Login
|
||||
expects user objects to have.
|
||||
"""
|
||||
|
||||
# Python 3 implicitly set __hash__ to None if we override __eq__
|
||||
# We set it back to its default implementation
|
||||
__hash__ = object.__hash__
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return self.is_active
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
def get_id(self):
|
||||
try:
|
||||
return str(self.id)
|
||||
except AttributeError:
|
||||
raise NotImplementedError("No `id` attribute - override `get_id`") from None
|
||||
|
||||
def __eq__(self, other):
|
||||
"""
|
||||
Checks the equality of two `UserMixin` objects using `get_id`.
|
||||
"""
|
||||
if isinstance(other, UserMixin):
|
||||
return self.get_id() == other.get_id()
|
||||
return NotImplemented
|
||||
|
||||
def __ne__(self, other):
|
||||
"""
|
||||
Checks the inequality of two `UserMixin` objects using `get_id`.
|
||||
"""
|
||||
equal = self.__eq__(other)
|
||||
if equal is NotImplemented:
|
||||
return NotImplemented
|
||||
return not equal
|
||||
|
||||
|
||||
class AnonymousUserMixin:
|
||||
"""
|
||||
This is the default object for representing an anonymous user.
|
||||
"""
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_anonymous(self):
|
||||
return True
|
||||
|
||||
def get_id(self):
|
||||
return
|
61
cps/cw_login/signals.py
Normal file
61
cps/cw_login/signals.py
Normal file
@ -0,0 +1,61 @@
|
||||
from flask.signals import Namespace
|
||||
|
||||
_signals = Namespace()
|
||||
|
||||
#: Sent when a user is logged in. In addition to the app (which is the
|
||||
#: sender), it is passed `user`, which is the user being logged in.
|
||||
user_logged_in = _signals.signal("logged-in")
|
||||
|
||||
#: Sent when a user is logged out. In addition to the app (which is the
|
||||
#: sender), it is passed `user`, which is the user being logged out.
|
||||
user_logged_out = _signals.signal("logged-out")
|
||||
|
||||
#: Sent when the user is loaded from the cookie. In addition to the app (which
|
||||
#: is the sender), it is passed `user`, which is the user being reloaded.
|
||||
user_loaded_from_cookie = _signals.signal("loaded-from-cookie")
|
||||
|
||||
#: Sent when the user is loaded from the header. In addition to the app (which
|
||||
#: is the #: sender), it is passed `user`, which is the user being reloaded.
|
||||
_user_loaded_from_header = _signals.signal("loaded-from-header")
|
||||
|
||||
#: Sent when the user is loaded from the request. In addition to the app (which
|
||||
#: is the #: sender), it is passed `user`, which is the user being reloaded.
|
||||
user_loaded_from_request = _signals.signal("loaded-from-request")
|
||||
|
||||
#: Sent when a user's login is confirmed, marking it as fresh. (It is not
|
||||
#: called for a normal login.)
|
||||
#: It receives no additional arguments besides the app.
|
||||
user_login_confirmed = _signals.signal("login-confirmed")
|
||||
|
||||
#: Sent when the `unauthorized` method is called on a `LoginManager`. It
|
||||
#: receives no additional arguments besides the app.
|
||||
user_unauthorized = _signals.signal("unauthorized")
|
||||
|
||||
#: Sent when the `needs_refresh` method is called on a `LoginManager`. It
|
||||
#: receives no additional arguments besides the app.
|
||||
user_needs_refresh = _signals.signal("needs-refresh")
|
||||
|
||||
#: Sent whenever the user is accessed/loaded
|
||||
#: receives no additional arguments besides the app.
|
||||
user_accessed = _signals.signal("accessed")
|
||||
|
||||
#: Sent whenever session protection takes effect, and a session is either
|
||||
#: marked non-fresh or deleted. It receives no additional arguments besides
|
||||
#: the app.
|
||||
session_protected = _signals.signal("session-protected")
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name == "user_loaded_from_header":
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"'user_loaded_from_header' is deprecated and will be"
|
||||
" removed in Flask-Login 0.7. Use"
|
||||
" 'user_loaded_from_request' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return _user_loaded_from_header
|
||||
|
||||
raise AttributeError(name)
|
424
cps/cw_login/utils.py
Normal file
424
cps/cw_login/utils.py
Normal file
@ -0,0 +1,424 @@
|
||||
import hmac
|
||||
import os
|
||||
from functools import wraps
|
||||
from hashlib import sha512
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlunsplit
|
||||
|
||||
from flask import current_app
|
||||
from flask import g
|
||||
from flask import has_request_context
|
||||
from flask import request
|
||||
from flask import session
|
||||
from flask import url_for
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from .config import COOKIE_NAME
|
||||
from .config import EXEMPT_METHODS
|
||||
from .signals import user_logged_in
|
||||
from .signals import user_logged_out
|
||||
from .signals import user_login_confirmed
|
||||
|
||||
#: A proxy for the current user. If no user is logged in, this will be an
|
||||
#: anonymous user
|
||||
current_user = LocalProxy(lambda: _get_user())
|
||||
|
||||
|
||||
def encode_cookie(payload, key=None):
|
||||
"""
|
||||
This will encode a ``str`` value into a cookie, and sign that cookie
|
||||
with the app's secret key.
|
||||
|
||||
:param payload: The value to encode, as `str`.
|
||||
:type payload: str
|
||||
|
||||
:param key: The key to use when creating the cookie digest. If not
|
||||
specified, the SECRET_KEY value from app config will be used.
|
||||
:type key: str
|
||||
"""
|
||||
return f"{payload}|{_cookie_digest(payload, key=key)}"
|
||||
|
||||
|
||||
def decode_cookie(cookie, key=None):
|
||||
"""
|
||||
This decodes a cookie given by `encode_cookie`. If verification of the
|
||||
cookie fails, ``None`` will be implicitly returned.
|
||||
|
||||
:param cookie: An encoded cookie.
|
||||
:type cookie: str
|
||||
|
||||
:param key: The key to use when creating the cookie digest. If not
|
||||
specified, the SECRET_KEY value from app config will be used.
|
||||
:type key: str
|
||||
"""
|
||||
try:
|
||||
payload, digest = cookie.rsplit("|", 1)
|
||||
if hasattr(digest, "decode"):
|
||||
digest = digest.decode("ascii") # pragma: no cover
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if hmac.compare_digest(_cookie_digest(payload, key=key), digest):
|
||||
return payload
|
||||
|
||||
|
||||
def make_next_param(login_url, current_url):
|
||||
"""
|
||||
Reduces the scheme and host from a given URL so it can be passed to
|
||||
the given `login` URL more efficiently.
|
||||
|
||||
:param login_url: The login URL being redirected to.
|
||||
:type login_url: str
|
||||
:param current_url: The URL to reduce.
|
||||
:type current_url: str
|
||||
"""
|
||||
l_url = urlsplit(login_url)
|
||||
c_url = urlsplit(current_url)
|
||||
|
||||
if (not l_url.scheme or l_url.scheme == c_url.scheme) and (
|
||||
not l_url.netloc or l_url.netloc == c_url.netloc
|
||||
):
|
||||
return urlunsplit(("", "", c_url.path, c_url.query, ""))
|
||||
return current_url
|
||||
|
||||
|
||||
def expand_login_view(login_view):
|
||||
"""
|
||||
Returns the url for the login view, expanding the view name to a url if
|
||||
needed.
|
||||
|
||||
:param login_view: The name of the login view or a URL for the login view.
|
||||
:type login_view: str
|
||||
"""
|
||||
if login_view.startswith(("https://", "http://", "/")):
|
||||
return login_view
|
||||
|
||||
return url_for(login_view)
|
||||
|
||||
|
||||
def login_url(login_view, next_url=None, next_field="next"):
|
||||
"""
|
||||
Creates a URL for redirecting to a login page. If only `login_view` is
|
||||
provided, this will just return the URL for it. If `next_url` is provided,
|
||||
however, this will append a ``next=URL`` parameter to the query string
|
||||
so that the login view can redirect back to that URL. Flask-Login's default
|
||||
unauthorized handler uses this function when redirecting to your login url.
|
||||
To force the host name used, set `FORCE_HOST_FOR_REDIRECTS` to a host. This
|
||||
prevents from redirecting to external sites if request headers Host or
|
||||
X-Forwarded-For are present.
|
||||
|
||||
:param login_view: The name of the login view. (Alternately, the actual
|
||||
URL to the login view.)
|
||||
:type login_view: str
|
||||
:param next_url: The URL to give the login view for redirection.
|
||||
:type next_url: str
|
||||
:param next_field: What field to store the next URL in. (It defaults to
|
||||
``next``.)
|
||||
:type next_field: str
|
||||
"""
|
||||
base = expand_login_view(login_view)
|
||||
|
||||
if next_url is None:
|
||||
return base
|
||||
|
||||
parsed_result = urlsplit(base)
|
||||
md = parse_qs(parsed_result.query, keep_blank_values=True)
|
||||
md[next_field] = make_next_param(base, next_url)
|
||||
netloc = current_app.config.get("FORCE_HOST_FOR_REDIRECTS") or parsed_result.netloc
|
||||
parsed_result = parsed_result._replace(
|
||||
netloc=netloc, query=urlencode(md, doseq=True)
|
||||
)
|
||||
return urlunsplit(parsed_result)
|
||||
|
||||
|
||||
def login_fresh():
|
||||
"""
|
||||
This returns ``True`` if the current login is fresh.
|
||||
"""
|
||||
return session.get("_fresh", False)
|
||||
|
||||
|
||||
def login_remembered():
|
||||
"""
|
||||
This returns ``True`` if the current login is remembered across sessions.
|
||||
"""
|
||||
config = current_app.config
|
||||
cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||
has_cookie = cookie_name in request.cookies and session.get("_remember") != "clear"
|
||||
if has_cookie:
|
||||
cookie = request.cookies[cookie_name]
|
||||
user_id = decode_cookie(cookie)
|
||||
return user_id is not None
|
||||
return False
|
||||
|
||||
|
||||
def login_user(user, remember=False, duration=None, force=False, fresh=True):
|
||||
"""
|
||||
Logs a user in. You should pass the actual user object to this. If the
|
||||
user's `is_active` property is ``False``, they will not be logged in
|
||||
unless `force` is ``True``.
|
||||
|
||||
This will return ``True`` if the log in attempt succeeds, and ``False`` if
|
||||
it fails (i.e. because the user is inactive).
|
||||
|
||||
:param user: The user object to log in.
|
||||
:type user: object
|
||||
:param remember: Whether to remember the user after their session expires.
|
||||
Defaults to ``False``.
|
||||
:type remember: bool
|
||||
:param duration: The amount of time before the remember cookie expires. If
|
||||
``None`` the value set in the settings is used. Defaults to ``None``.
|
||||
:type duration: :class:`datetime.timedelta`
|
||||
:param force: If the user is inactive, setting this to ``True`` will log
|
||||
them in regardless. Defaults to ``False``.
|
||||
:type force: bool
|
||||
:param fresh: setting this to ``False`` will log in the user with a session
|
||||
marked as not "fresh". Defaults to ``True``.
|
||||
:type fresh: bool
|
||||
"""
|
||||
if not force and not user.is_active:
|
||||
return False
|
||||
|
||||
user_id = getattr(user, current_app.login_manager.id_attribute)()
|
||||
session["_user_id"] = user_id
|
||||
session["_fresh"] = fresh
|
||||
session["_id"] = current_app.login_manager._session_identifier_generator()
|
||||
session["_random"] = os.urandom(10).hex()
|
||||
|
||||
if remember:
|
||||
session["_remember"] = "set"
|
||||
if duration is not None:
|
||||
try:
|
||||
# equal to timedelta.total_seconds() but works with Python 2.6
|
||||
session["_remember_seconds"] = (
|
||||
duration.microseconds
|
||||
+ (duration.seconds + duration.days * 24 * 3600) * 10**6
|
||||
) / 10.0**6
|
||||
except AttributeError as e:
|
||||
raise Exception(
|
||||
f"duration must be a datetime.timedelta, instead got: {duration}"
|
||||
) from e
|
||||
|
||||
current_app.login_manager._update_request_context_with_user(user)
|
||||
user_logged_in.send(current_app._get_current_object(), user=_get_user())
|
||||
return True
|
||||
|
||||
|
||||
def logout_user():
|
||||
"""
|
||||
Logs a user out. (You do not need to pass the actual user.) This will
|
||||
also clean up the remember me cookie if it exists.
|
||||
"""
|
||||
|
||||
user = _get_user()
|
||||
|
||||
if "_user_id" in session:
|
||||
session.pop("_user_id")
|
||||
|
||||
if "_fresh" in session:
|
||||
session.pop("_fresh")
|
||||
|
||||
if "_id" in session:
|
||||
session.pop("_id")
|
||||
|
||||
if "_random" in session:
|
||||
session.pop("_random")
|
||||
|
||||
|
||||
cookie_name = current_app.config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
|
||||
if cookie_name in request.cookies:
|
||||
session["_remember"] = "clear"
|
||||
if "_remember_seconds" in session:
|
||||
session.pop("_remember_seconds")
|
||||
|
||||
user_logged_out.send(current_app._get_current_object(), user=user)
|
||||
|
||||
current_app.login_manager._update_request_context_with_user()
|
||||
return True
|
||||
|
||||
|
||||
def confirm_login():
|
||||
"""
|
||||
This sets the current session as fresh. Sessions become stale when they
|
||||
are reloaded from a cookie.
|
||||
"""
|
||||
session["_fresh"] = True
|
||||
session["_id"] = current_app.login_manager._session_identifier_generator()
|
||||
user_login_confirmed.send(current_app._get_current_object())
|
||||
|
||||
|
||||
def login_required(func):
|
||||
"""
|
||||
If you decorate a view with this, it will ensure that the current user is
|
||||
logged in and authenticated before calling the actual view. (If they are
|
||||
not, it calls the :attr:`LoginManager.unauthorized` callback.) For
|
||||
example::
|
||||
|
||||
@app.route('/post')
|
||||
@user_login_required
|
||||
def post():
|
||||
pass
|
||||
|
||||
If there are only certain times you need to require that your user is
|
||||
logged in, you can do so with::
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized()
|
||||
|
||||
...which is essentially the code that this function adds to your views.
|
||||
|
||||
It can be convenient to globally turn off authentication when unit testing.
|
||||
To enable this, if the application configuration variable `LOGIN_DISABLED`
|
||||
is set to `True`, this decorator will be ignored.
|
||||
|
||||
.. Note ::
|
||||
|
||||
Per `W3 guidelines for CORS preflight requests
|
||||
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
|
||||
HTTP ``OPTIONS`` requests are exempt from login checks.
|
||||
|
||||
:param func: The view function to decorate.
|
||||
:type func: function
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def decorated_view(*args, **kwargs):
|
||||
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
|
||||
pass
|
||||
elif not current_user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized()
|
||||
|
||||
# flask 1.x compatibility
|
||||
# current_app.ensure_sync is only available in Flask >= 2.0
|
||||
if callable(getattr(current_app, "ensure_sync", None)):
|
||||
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return decorated_view
|
||||
|
||||
|
||||
def fresh_login_required(func):
|
||||
"""
|
||||
If you decorate a view with this, it will ensure that the current user's
|
||||
login is fresh - i.e. their session was not restored from a 'remember me'
|
||||
cookie. Sensitive operations, like changing a password or e-mail, should
|
||||
be protected with this, to impede the efforts of cookie thieves.
|
||||
|
||||
If the user is not authenticated, :meth:`LoginManager.unauthorized` is
|
||||
called as normal. If they are authenticated, but their session is not
|
||||
fresh, it will call :meth:`LoginManager.needs_refresh` instead. (In that
|
||||
case, you will need to provide a :attr:`LoginManager.refresh_view`.)
|
||||
|
||||
Behaves identically to the :func:`login_required` decorator with respect
|
||||
to configuration variables.
|
||||
|
||||
.. Note ::
|
||||
|
||||
Per `W3 guidelines for CORS preflight requests
|
||||
<http://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0>`_,
|
||||
HTTP ``OPTIONS`` requests are exempt from login checks.
|
||||
|
||||
:param func: The view function to decorate.
|
||||
:type func: function
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def decorated_view(*args, **kwargs):
|
||||
if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
|
||||
pass
|
||||
elif not current_user.is_authenticated:
|
||||
return current_app.login_manager.unauthorized()
|
||||
elif not login_fresh():
|
||||
return current_app.login_manager.needs_refresh()
|
||||
try:
|
||||
# current_app.ensure_sync available in Flask >= 2.0
|
||||
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||
except AttributeError: # pragma: no cover
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return decorated_view
|
||||
|
||||
|
||||
def set_login_view(login_view, blueprint=None):
|
||||
"""
|
||||
Sets the login view for the app or blueprint. If a blueprint is passed,
|
||||
the login view is set for this blueprint on ``blueprint_login_views``.
|
||||
|
||||
:param login_view: The user object to log in.
|
||||
:type login_view: str
|
||||
:param blueprint: The blueprint which this login view should be set on.
|
||||
Defaults to ``None``.
|
||||
:type blueprint: object
|
||||
"""
|
||||
|
||||
num_login_views = len(current_app.login_manager.blueprint_login_views)
|
||||
if blueprint is not None or num_login_views != 0:
|
||||
(current_app.login_manager.blueprint_login_views[blueprint.name]) = login_view
|
||||
|
||||
if (
|
||||
current_app.login_manager.login_view is not None
|
||||
and None not in current_app.login_manager.blueprint_login_views
|
||||
):
|
||||
(
|
||||
current_app.login_manager.blueprint_login_views[None]
|
||||
) = current_app.login_manager.login_view
|
||||
|
||||
current_app.login_manager.login_view = None
|
||||
else:
|
||||
current_app.login_manager.login_view = login_view
|
||||
|
||||
|
||||
def _get_user():
|
||||
if has_request_context():
|
||||
if "flask_httpauth_user" in g:
|
||||
if g.flask_httpauth_user is not None:
|
||||
return g.flask_httpauth_user
|
||||
if "_login_user" not in g:
|
||||
current_app.login_manager._load_user()
|
||||
|
||||
return g._login_user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _cookie_digest(payload, key=None):
|
||||
key = _secret_key(key)
|
||||
|
||||
return hmac.new(key, payload.encode("utf-8"), sha512).hexdigest()
|
||||
|
||||
|
||||
def _get_remote_addr():
|
||||
address = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||
if address is not None:
|
||||
# An 'X-Forwarded-For' header includes a comma separated list of the
|
||||
# addresses, the first address being the actual remote address.
|
||||
address = address.encode("utf-8").split(b",")[0].strip()
|
||||
return address
|
||||
|
||||
|
||||
def _create_identifier():
|
||||
user_agent = request.headers.get("User-Agent")
|
||||
if user_agent is not None:
|
||||
user_agent = user_agent.encode("utf-8")
|
||||
base = f"{_get_remote_addr()}|{user_agent}"
|
||||
if str is bytes:
|
||||
base = str(base, "utf-8", errors="replace") # pragma: no cover
|
||||
h = sha512()
|
||||
h.update(base.encode("utf8"))
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _user_context_processor():
|
||||
return dict(current_user=_get_user())
|
||||
|
||||
|
||||
def _secret_key(key=None):
|
||||
if key is None:
|
||||
key = current_app.config["SECRET_KEY"]
|
||||
|
||||
if isinstance(key, str): # pragma: no cover
|
||||
key = key.encode("latin1") # ensure bytes
|
||||
|
||||
return key
|
26
cps/db.py
26
cps/db.py
@ -20,9 +20,10 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote
|
||||
import unidecode
|
||||
from weakref import WeakSet
|
||||
|
||||
from sqlite3 import OperationalError as sqliteOperationalError
|
||||
from sqlalchemy import create_engine
|
||||
@ -40,16 +41,14 @@ except ImportError:
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.sql.expression import and_, true, false, text, func, or_
|
||||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import get_locale
|
||||
from flask import flash
|
||||
|
||||
from . import logger, ub, isoLanguages
|
||||
from .pagination import Pagination
|
||||
|
||||
from weakref import WeakSet
|
||||
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@ -379,10 +378,10 @@ class Books(Base):
|
||||
title = Column(String(collation='NOCASE'), nullable=False, default='Unknown')
|
||||
sort = Column(String(collation='NOCASE'))
|
||||
author_sort = Column(String(collation='NOCASE'))
|
||||
timestamp = Column(TIMESTAMP, default=datetime.utcnow)
|
||||
timestamp = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
|
||||
pubdate = Column(TIMESTAMP, default=DEFAULT_PUBDATE)
|
||||
series_index = Column(String, nullable=False, default="1.0")
|
||||
last_modified = Column(TIMESTAMP, default=datetime.utcnow)
|
||||
last_modified = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc))
|
||||
path = Column(String, default="", nullable=False)
|
||||
has_cover = Column(Integer, default=0)
|
||||
uuid = Column(String)
|
||||
@ -876,10 +875,11 @@ class CalibreDB:
|
||||
authors_ordered = list()
|
||||
# error = False
|
||||
for auth in sort_authors:
|
||||
results = self.session.query(Authors).filter(Authors.sort == auth.lstrip().strip()).all()
|
||||
auth = strip_whitespaces(auth)
|
||||
results = self.session.query(Authors).filter(Authors.sort == auth).all()
|
||||
# ToDo: How to handle not found author name
|
||||
if not len(results):
|
||||
log.error("Author {} not found to display name in right order".format(auth.strip()))
|
||||
log.error("Author {} not found to display name in right order".format(auth))
|
||||
# error = True
|
||||
break
|
||||
for r in results:
|
||||
@ -919,7 +919,7 @@ class CalibreDB:
|
||||
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
|
||||
|
||||
def search_query(self, term, config, *join):
|
||||
term.strip().lower()
|
||||
strip_whitespaces(term).lower()
|
||||
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||
q = list()
|
||||
author_terms = re.split("[, ]+", term)
|
||||
@ -1027,13 +1027,13 @@ class CalibreDB:
|
||||
if match:
|
||||
prep = match.group(1)
|
||||
title = title[len(prep):] + ', ' + prep
|
||||
return title.strip()
|
||||
return strip_whitespaces(title)
|
||||
|
||||
try:
|
||||
# sqlalchemy <1.4.24
|
||||
# sqlalchemy <1.4.24 and sqlalchemy 2.0
|
||||
conn = conn or self.session.connection().connection.driver_connection
|
||||
except AttributeError:
|
||||
# sqlalchemy >1.4.24 and sqlalchemy 2.0
|
||||
# sqlalchemy >1.4.24
|
||||
conn = conn or self.session.connection().connection.connection
|
||||
try:
|
||||
conn.create_function("title_sort", 1, _title_sort)
|
||||
|
@ -26,7 +26,8 @@ from flask_babel.speaklater import LazyString
|
||||
|
||||
import os
|
||||
|
||||
from flask import send_file, __version__
|
||||
from flask import send_file
|
||||
import importlib
|
||||
|
||||
from . import logger, config
|
||||
from .about import collect_stats
|
||||
@ -49,7 +50,8 @@ def assemble_logfiles(file_name):
|
||||
with open(f, 'rb') as fd:
|
||||
shutil.copyfileobj(fd, wfd)
|
||||
wfd.seek(0)
|
||||
if int(__version__.split('.')[0]) < 2:
|
||||
version = importlib.metadata.version("flask")
|
||||
if int(version.split('.')[0]) < 2:
|
||||
return send_file(wfd,
|
||||
as_attachment=True,
|
||||
attachment_filename=os.path.basename(file_name))
|
||||
@ -72,7 +74,8 @@ def send_debug():
|
||||
for fp in file_list:
|
||||
zf.write(fp, os.path.basename(fp))
|
||||
memory_zip.seek(0)
|
||||
if int(__version__.split('.')[0]) < 2:
|
||||
version = importlib.metadata.version("flask")
|
||||
if int(version.split('.')[0]) < 2:
|
||||
return send_file(memory_zip,
|
||||
as_attachment=True,
|
||||
attachment_filename="Calibre-Web-debug-pack.zip")
|
||||
|
@ -21,7 +21,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from shutil import copyfile
|
||||
from uuid import uuid4
|
||||
@ -32,7 +32,7 @@ from flask import Blueprint, request, flash, redirect, url_for, abort, Response
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import lazy_gettext as N_
|
||||
from flask_babel import get_locale
|
||||
from flask_login import current_user, login_required
|
||||
from .cw_login import current_user, login_required
|
||||
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
from sqlalchemy.sql.expression import func
|
||||
@ -43,10 +43,11 @@ from . import config, ub, db, calibre_db
|
||||
from .services.worker import WorkerThread
|
||||
from .tasks.upload import TaskUpload
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import login_required_if_no_ano
|
||||
from .kobo_sync_status import change_archived_books
|
||||
from .redirect import get_redirect_location
|
||||
from .file_helper import validate_mime_type
|
||||
from .usermanagement import user_login_required, login_required_if_no_ano
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
editbook = Blueprint('edit-book', __name__)
|
||||
log = logger.create()
|
||||
@ -73,14 +74,14 @@ def edit_required(f):
|
||||
|
||||
|
||||
@editbook.route("/ajax/delete/<int:book_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def delete_book_from_details(book_id):
|
||||
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
|
||||
|
||||
|
||||
@editbook.route("/delete/<int:book_id>", defaults={'book_format': ""}, methods=["POST"])
|
||||
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def delete_book_ajax(book_id, book_format):
|
||||
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
|
||||
|
||||
@ -133,8 +134,9 @@ def edit_book(book_id):
|
||||
# handle upload other formats from local disk
|
||||
meta = upload_single_file(request, book, book_id)
|
||||
# only merge metadata if file was uploaded and no error occurred (meta equals not false or none)
|
||||
upload_format = False
|
||||
if meta:
|
||||
merge_metadata(to_save, meta)
|
||||
upload_format = merge_metadata(to_save, meta)
|
||||
# handle upload covers from local disk
|
||||
cover_upload_success = upload_cover(request, book)
|
||||
if cover_upload_success:
|
||||
@ -178,7 +180,7 @@ def edit_book(book_id):
|
||||
modify_date |= edit_book_publisher(to_save['publisher'], book)
|
||||
# handle book languages
|
||||
try:
|
||||
modify_date |= edit_book_languages(to_save['languages'], book)
|
||||
modify_date |= edit_book_languages(to_save['languages'], book, upload_format)
|
||||
except ValueError as e:
|
||||
flash(str(e), category="error")
|
||||
edit_error = True
|
||||
@ -198,7 +200,7 @@ def edit_book(book_id):
|
||||
book.pubdate = db.Books.DEFAULT_PUBDATE
|
||||
|
||||
if modify_date:
|
||||
book.last_modified = datetime.utcnow()
|
||||
book.last_modified = datetime.now(timezone.utc)
|
||||
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
|
||||
calibre_db.set_metadata_dirty(book.id)
|
||||
|
||||
@ -244,8 +246,12 @@ def upload():
|
||||
modify_date = False
|
||||
# create the function for sorting...
|
||||
calibre_db.update_title_sort(config)
|
||||
calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4()))
|
||||
|
||||
try:
|
||||
# sqlalchemy 2.0
|
||||
uuid_func = calibre_db.session.connection().connection.driver_connection
|
||||
except AttributeError:
|
||||
uuid_func = calibre_db.session.connection().connection.connection
|
||||
uuid_func.create_function('uuid4', 0,lambda: str(uuid4()))
|
||||
meta, error = file_handling_on_upload(requested_file)
|
||||
if error:
|
||||
return error
|
||||
@ -331,7 +337,7 @@ def convert_bookformat(book_id):
|
||||
|
||||
|
||||
@editbook.route("/ajax/getcustomenum/<int:c_id>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
def table_get_custom_enum(c_id):
|
||||
ret = list()
|
||||
cc = (calibre_db.session.query(db.CustomColumns)
|
||||
@ -438,7 +444,7 @@ def edit_list_book(param):
|
||||
mimetype='application/json')
|
||||
else:
|
||||
return _("Parameter not found"), 400
|
||||
book.last_modified = datetime.utcnow()
|
||||
book.last_modified = datetime.now(timezone.utc)
|
||||
|
||||
calibre_db.session.commit()
|
||||
# revert change for sort if automatic fields link is deactivated
|
||||
@ -455,7 +461,7 @@ def edit_list_book(param):
|
||||
|
||||
|
||||
@editbook.route("/ajax/sort_value/<field>/<int:bookid>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
def get_sorted_entry(field, bookid):
|
||||
if field in ['title', 'authors', 'sort', 'author_sort']:
|
||||
book = calibre_db.get_filtered_book(bookid)
|
||||
@ -472,7 +478,7 @@ def get_sorted_entry(field, bookid):
|
||||
|
||||
|
||||
@editbook.route("/ajax/simulatemerge", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@edit_required
|
||||
def simulate_merge_list_book():
|
||||
vals = request.get_json().get('Merge_books')
|
||||
@ -488,7 +494,7 @@ def simulate_merge_list_book():
|
||||
|
||||
|
||||
@editbook.route("/ajax/mergebooks", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@edit_required
|
||||
def merge_list_book():
|
||||
vals = request.get_json().get('Merge_books')
|
||||
@ -526,7 +532,7 @@ def merge_list_book():
|
||||
|
||||
|
||||
@editbook.route("/ajax/xchange", methods=['POST'])
|
||||
@login_required
|
||||
@user_login_required
|
||||
@edit_required
|
||||
def table_xchange_author_title():
|
||||
vals = request.get_json().get('xchange')
|
||||
@ -554,7 +560,7 @@ def table_xchange_author_title():
|
||||
# toDo: Handle error
|
||||
edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0])
|
||||
if modify_date:
|
||||
book.last_modified = datetime.utcnow()
|
||||
book.last_modified = datetime.now(timezone.utc)
|
||||
calibre_db.set_metadata_dirty(book.id)
|
||||
try:
|
||||
calibre_db.session.commit()
|
||||
@ -574,6 +580,10 @@ def merge_metadata(to_save, meta):
|
||||
to_save['author_name'] = ''
|
||||
if to_save.get('book_title', "") == _('Unknown'):
|
||||
to_save['book_title'] = ''
|
||||
if not to_save["languages"] and meta.languages:
|
||||
upload_language = True
|
||||
else:
|
||||
upload_language = False
|
||||
for s_field, m_field in [
|
||||
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
|
||||
('series_index', 'series_id'), ('languages', 'languages'),
|
||||
@ -581,7 +591,7 @@ def merge_metadata(to_save, meta):
|
||||
to_save[s_field] = to_save[s_field] or getattr(meta, m_field, '')
|
||||
to_save["description"] = to_save["description"] or Markup(
|
||||
getattr(meta, 'description', '')).unescape()
|
||||
|
||||
return upload_language
|
||||
|
||||
def identifier_list(to_save, book):
|
||||
"""Generate a list of Identifiers from form information"""
|
||||
@ -701,8 +711,8 @@ def create_book_on_upload(modify_date, meta):
|
||||
pubdate = datetime(101, 1, 1)
|
||||
|
||||
# Calibre adds books with utc as timezone
|
||||
db_book = db.Books(title, "", sort_authors, datetime.utcnow(), pubdate,
|
||||
'1', datetime.utcnow(), path, meta.cover, db_author, [], "")
|
||||
db_book = db.Books(title, "", sort_authors, datetime.now(timezone.utc), pubdate,
|
||||
'1', datetime.now(timezone.utc), path, meta.cover, db_author, [], "")
|
||||
|
||||
modify_date |= modify_database_object(input_authors, db_book.authors, db.Authors, calibre_db.session,
|
||||
'author')
|
||||
@ -969,7 +979,7 @@ def render_edit_book(book_id):
|
||||
|
||||
def edit_book_ratings(to_save, book):
|
||||
changed = False
|
||||
if to_save.get("rating", "").strip():
|
||||
if strip_whitespaces(to_save.get("rating", "")):
|
||||
old_rating = False
|
||||
if len(book.ratings) > 0:
|
||||
old_rating = book.ratings[0].rating
|
||||
@ -993,14 +1003,14 @@ def edit_book_ratings(to_save, book):
|
||||
|
||||
def edit_book_tags(tags, book):
|
||||
input_tags = tags.split(',')
|
||||
input_tags = list(map(lambda it: it.strip(), input_tags))
|
||||
input_tags = list(map(lambda it: strip_whitespaces(it), input_tags))
|
||||
# Remove duplicates
|
||||
input_tags = helper.uniq(input_tags)
|
||||
return modify_database_object(input_tags, book.tags, db.Tags, calibre_db.session, 'tags')
|
||||
|
||||
|
||||
def edit_book_series(series, book):
|
||||
input_series = [series.strip()]
|
||||
input_series = [strip_whitespaces(series)]
|
||||
input_series = [x for x in input_series if x != '']
|
||||
return modify_database_object(input_series, book.series, db.Series, calibre_db.session, 'series')
|
||||
|
||||
@ -1010,7 +1020,7 @@ def edit_book_series_index(series_index, book):
|
||||
modify_date = False
|
||||
series_index = series_index or '1'
|
||||
if not series_index.replace('.', '', 1).isdigit():
|
||||
flash(_("%(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
|
||||
flash(_("Seriesindex: %(seriesindex)s is not a valid number, skipping", seriesindex=series_index), category="warning")
|
||||
return False
|
||||
if str(book.series_index) != series_index:
|
||||
book.series_index = series_index
|
||||
@ -1062,7 +1072,7 @@ def edit_book_languages(languages, book, upload_mode=False, invalid=None):
|
||||
def edit_book_publisher(publishers, book):
|
||||
changed = False
|
||||
if publishers:
|
||||
publisher = publishers.rstrip().strip()
|
||||
publisher = strip_whitespaces(publishers)
|
||||
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,
|
||||
'publisher')
|
||||
@ -1109,7 +1119,7 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
|
||||
changed = False
|
||||
if c.datatype == 'rating':
|
||||
to_save[cc_string] = str(int(float(to_save[cc_string]) * 2))
|
||||
if to_save[cc_string].strip() != cc_db_value:
|
||||
if strip_whitespaces(to_save[cc_string]) != cc_db_value:
|
||||
if cc_db_value is not None:
|
||||
# remove old cc_val
|
||||
del_cc = getattr(book, cc_string)[0]
|
||||
@ -1119,15 +1129,15 @@ def edit_cc_data_string(book, c, to_save, cc_db_value, cc_string):
|
||||
changed = True
|
||||
cc_class = db.cc_classes[c.id]
|
||||
new_cc = calibre_db.session.query(cc_class).filter(
|
||||
cc_class.value == to_save[cc_string].strip()).first()
|
||||
cc_class.value == strip_whitespaces(to_save[cc_string])).first()
|
||||
# if no cc val is found add it
|
||||
if new_cc is None:
|
||||
new_cc = cc_class(value=to_save[cc_string].strip())
|
||||
new_cc = cc_class(value=strip_whitespaces(to_save[cc_string]))
|
||||
calibre_db.session.add(new_cc)
|
||||
changed = True
|
||||
calibre_db.session.flush()
|
||||
new_cc = calibre_db.session.query(cc_class).filter(
|
||||
cc_class.value == to_save[cc_string].strip()).first()
|
||||
cc_class.value == strip_whitespaces(to_save[cc_string])).first()
|
||||
# add cc value to book
|
||||
getattr(book, cc_string).append(new_cc)
|
||||
return changed, to_save
|
||||
@ -1155,7 +1165,7 @@ def edit_cc_data(book_id, book, to_save, cc):
|
||||
cc_db_value = getattr(book, cc_string)[0].value
|
||||
else:
|
||||
cc_db_value = None
|
||||
if to_save[cc_string].strip():
|
||||
if strip_whitespaces(to_save[cc_string]):
|
||||
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
|
||||
change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
|
||||
else:
|
||||
@ -1171,7 +1181,7 @@ def edit_cc_data(book_id, book, to_save, cc):
|
||||
changed = True
|
||||
else:
|
||||
input_tags = to_save[cc_string].split(',')
|
||||
input_tags = list(map(lambda it: it.strip(), input_tags))
|
||||
input_tags = list(map(lambda it: strip_whitespaces(it), input_tags))
|
||||
changed |= modify_database_object(input_tags,
|
||||
getattr(book, cc_string),
|
||||
db.cc_classes[c.id],
|
||||
@ -1274,7 +1284,7 @@ def upload_cover(cover_request, book):
|
||||
|
||||
def handle_title_on_edit(book, book_title):
|
||||
# handle book title
|
||||
book_title = book_title.rstrip().strip()
|
||||
book_title = strip_whitespaces(book_title)
|
||||
if book.title != book_title:
|
||||
if book_title == '':
|
||||
book_title = _(u'Unknown')
|
||||
|
@ -28,7 +28,7 @@ log = logger.create()
|
||||
|
||||
def do_calibre_export(book_id, book_format):
|
||||
try:
|
||||
quotes = [3, 5, 7, 9]
|
||||
quotes = [4, 6]
|
||||
tmp_dir = get_temp_dir()
|
||||
calibredb_binarypath = get_calibre_binarypath("calibredb")
|
||||
temp_file_name = str(uuid4())
|
||||
|
@ -25,6 +25,7 @@ from . import config, logger
|
||||
from .helper import split_authors
|
||||
from .epub_helper import get_content_opf, default_ns
|
||||
from .constants import BookMeta
|
||||
from .string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@ -90,7 +91,7 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
elif s == 'date':
|
||||
epub_metadata[s] = tmp[0][:10]
|
||||
else:
|
||||
epub_metadata[s] = tmp[0].strip()
|
||||
epub_metadata[s] = strip_whitespaces(tmp[0])
|
||||
else:
|
||||
epub_metadata[s] = 'Unknown'
|
||||
|
||||
|
@ -29,8 +29,9 @@ log = logger.create()
|
||||
|
||||
try:
|
||||
import magic
|
||||
error = None
|
||||
except ImportError as e:
|
||||
log.error("Cannot import python-magic, checking uploaded file metadata will not work: %s", e)
|
||||
error = "Cannot import python-magic, checking uploaded file metadata will not work: {}".format(e)
|
||||
|
||||
|
||||
def get_temp_dir():
|
||||
@ -46,6 +47,9 @@ def del_temp_dir():
|
||||
|
||||
|
||||
def validate_mime_type(file_buffer, allowed_extensions):
|
||||
if error:
|
||||
log.error(error)
|
||||
return False
|
||||
mime = magic.Magic(mime=True)
|
||||
allowed_mimetypes = list()
|
||||
for x in allowed_extensions:
|
||||
|
@ -29,11 +29,11 @@ from shutil import move, copyfile
|
||||
|
||||
from flask import Blueprint, flash, request, redirect, url_for, abort
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required
|
||||
|
||||
from . import logger, gdriveutils, config, ub, calibre_db, csrf
|
||||
from .admin import admin_required
|
||||
from .file_helper import get_temp_dir
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
|
||||
log = logger.create()
|
||||
@ -49,7 +49,7 @@ gdrive_watch_callback_token = 'target=calibreweb-watch_files' # nosec
|
||||
|
||||
|
||||
@gdrive.route("/authenticate")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def authenticate_google_drive():
|
||||
try:
|
||||
@ -76,7 +76,7 @@ def google_drive_callback():
|
||||
|
||||
|
||||
@gdrive.route("/watch/subscribe")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def watch_gdrive():
|
||||
if not config.config_google_drive_watch_changes_response:
|
||||
@ -102,7 +102,7 @@ def watch_gdrive():
|
||||
|
||||
|
||||
@gdrive.route("/watch/revoke")
|
||||
@login_required
|
||||
@user_login_required
|
||||
@admin_required
|
||||
def revoke_watch_gdrive():
|
||||
last_watch_response = config.config_google_drive_watch_changes_response
|
||||
|
123
cps/helper.py
123
cps/helper.py
@ -25,7 +25,7 @@ import re
|
||||
import regex
|
||||
import shutil
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import requests
|
||||
import unidecode
|
||||
from uuid import uuid4
|
||||
@ -34,7 +34,7 @@ from flask import send_from_directory, make_response, abort, url_for, Response
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import lazy_gettext as N_
|
||||
from flask_babel import get_locale
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
from sqlalchemy.sql.expression import true, false, and_, or_, text, func
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from werkzeug.datastructures import Headers
|
||||
@ -52,6 +52,7 @@ except ImportError:
|
||||
UnacceptableAddressException = MissingSchema = BaseException
|
||||
|
||||
from . import calibre_db, cli_param
|
||||
from .string_helper import strip_whitespaces
|
||||
from .tasks.convert import TaskConvert
|
||||
from . import logger, config, db, ub, fs
|
||||
from . import gdriveutils as gd
|
||||
@ -118,7 +119,7 @@ def convert_book_format(book_id, calibre_path, old_book_format, new_book_format,
|
||||
# Texts are not lazy translated as they are supposed to get send out as is
|
||||
def send_test_mail(ereader_mail, user_name):
|
||||
for email in ereader_mail.split(','):
|
||||
email = email.strip()
|
||||
email = strip_whitespaces(email)
|
||||
WorkerThread.add(user_name, TaskEmail(_('Calibre-Web Test Email'), None, None,
|
||||
config.get_mail_settings(), email, N_("Test Email"),
|
||||
_('This Email has been sent via Calibre-Web.')))
|
||||
@ -228,7 +229,7 @@ def send_mail(book_id, book_format, convert, ereader_mail, calibrepath, user_id)
|
||||
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(book.title))
|
||||
email_text = N_("%(book)s send to eReader", book=link)
|
||||
for email in ereader_mail.split(','):
|
||||
email = email.strip()
|
||||
email = strip_whitespaces(email)
|
||||
WorkerThread.add(user_id, TaskEmail(_("Send to eReader"), book.path, converted_file_name,
|
||||
config.get_mail_settings(), email,
|
||||
email_text, _('This Email has been sent via Calibre-Web.'), book.id))
|
||||
@ -252,7 +253,7 @@ def get_valid_filename(value, replace_whitespace=True, chars=128):
|
||||
# pipe has to be replaced with comma
|
||||
value = re.sub(r'[|]+', ',', value, flags=re.U)
|
||||
|
||||
value = value.encode('utf-8')[:chars].decode('utf-8', errors='ignore').strip()
|
||||
value = strip_whitespaces(value.encode('utf-8')[:chars].decode('utf-8', errors='ignore'))
|
||||
|
||||
if not value:
|
||||
raise ValueError("Filename cannot be empty")
|
||||
@ -267,11 +268,11 @@ def split_authors(values):
|
||||
commas = author.count(',')
|
||||
if commas == 1:
|
||||
author_split = author.split(',')
|
||||
authors_list.append(author_split[1].strip() + ' ' + author_split[0].strip())
|
||||
authors_list.append(strip_whitespaces(author_split[1]) + ' ' + strip_whitespaces(author_split[0]))
|
||||
elif commas > 1:
|
||||
authors_list.extend([x.strip() for x in author.split(',')])
|
||||
authors_list.extend([strip_whitespaces(x) for x in author.split(',')])
|
||||
else:
|
||||
authors_list.append(author.strip())
|
||||
authors_list.append(strip_whitespaces(author))
|
||||
return authors_list
|
||||
|
||||
|
||||
@ -413,36 +414,6 @@ def rename_all_files_on_change(one_book, new_path, old_path, all_new_name, gdriv
|
||||
file_format.name = all_new_name
|
||||
|
||||
|
||||
'''def rename_all_authors(first_author, renamed_author, calibre_path="", localbook=None, gdrive=False):
|
||||
# Create new_author_dir from parameter or from database
|
||||
# Create new title_dir from database and add id
|
||||
if first_author:
|
||||
new_authordir = get_valid_filename(first_author, chars=96)
|
||||
for r in renamed_author:
|
||||
new_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == r).first()
|
||||
old_author_dir = get_valid_filename(r, chars=96)
|
||||
new_author_rename_dir = get_valid_filename(new_author.name, chars=96)
|
||||
if gdrive:
|
||||
g_file = gd.getFileFromEbooksFolder(None, old_author_dir)
|
||||
if g_file:
|
||||
gd.moveGdriveFolderRemote(g_file, new_author_rename_dir)
|
||||
gd.updateDatabaseOnEdit(g_file['id'], new_author_rename_dir)
|
||||
else:
|
||||
if os.path.isdir(os.path.join(calibre_path, old_author_dir)):
|
||||
old_author_path = os.path.join(calibre_path, old_author_dir)
|
||||
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
|
||||
try:
|
||||
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", old_author_path, new_author_path, 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, chars=96)
|
||||
return new_authordir'''
|
||||
|
||||
|
||||
def rename_author_path(first_author, old_author_dir, renamed_author, calibre_path="", gdrive=False):
|
||||
# Create new_author_dir from parameter or from database
|
||||
# Create new title_dir from database and add id
|
||||
@ -459,12 +430,15 @@ def rename_author_path(first_author, old_author_dir, renamed_author, calibre_pat
|
||||
old_author_path = os.path.join(calibre_path, old_author_dir)
|
||||
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
|
||||
try:
|
||||
shutil.move(old_author_path, new_author_path)
|
||||
except OSError as ex:
|
||||
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, 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))
|
||||
os.rename(old_author_path, new_author_path)
|
||||
except OSError:
|
||||
try:
|
||||
shutil.move(old_author_path, new_author_path)
|
||||
except OSError as ex:
|
||||
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
|
||||
log.error_or_exception(ex)
|
||||
raise Exception(_("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
|
||||
src=old_author_path, dest=new_author_path, error=str(ex)))
|
||||
return new_authordir
|
||||
|
||||
# Moves files in file storage during author/title rename, or from temp dir to file storage
|
||||
@ -688,7 +662,7 @@ def check_email(email):
|
||||
|
||||
|
||||
def check_username(username):
|
||||
username = username.strip()
|
||||
username = strip_whitespaces(username)
|
||||
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar():
|
||||
log.error("This username is already taken")
|
||||
raise Exception(_("This username is already taken"))
|
||||
@ -697,14 +671,14 @@ def check_username(username):
|
||||
|
||||
def valid_email(emails):
|
||||
for email in emails.split(','):
|
||||
email = email.strip()
|
||||
# if email is not deleted
|
||||
if email:
|
||||
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
|
||||
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
||||
email):
|
||||
log.error("Invalid Email address format")
|
||||
raise Exception(_("Invalid Email address format"))
|
||||
email = strip_whitespaces(email)
|
||||
# if email is not deleted
|
||||
if email:
|
||||
# Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
|
||||
if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$",
|
||||
email):
|
||||
log.error("Invalid Email address format")
|
||||
raise Exception(_("Invalid Email address format"))
|
||||
return email
|
||||
|
||||
|
||||
@ -815,24 +789,23 @@ def get_book_cover_internal(book, resolution=None):
|
||||
|
||||
def get_book_cover_thumbnail(book, resolution):
|
||||
if book and book.has_cover:
|
||||
return ub.session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER) \
|
||||
.filter(ub.Thumbnail.entity_id == book.id) \
|
||||
.filter(ub.Thumbnail.resolution == resolution) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||
.first()
|
||||
return (ub.session
|
||||
.query(ub.Thumbnail)
|
||||
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_COVER)
|
||||
.filter(ub.Thumbnail.entity_id == book.id)
|
||||
.filter(ub.Thumbnail.resolution == resolution)
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
|
||||
.first())
|
||||
|
||||
|
||||
def get_series_thumbnail_on_failure(series_id, resolution):
|
||||
book = calibre_db.session \
|
||||
.query(db.Books) \
|
||||
.join(db.books_series_link) \
|
||||
.join(db.Series) \
|
||||
.filter(db.Series.id == series_id) \
|
||||
.filter(db.Books.has_cover == 1) \
|
||||
.first()
|
||||
|
||||
book = (calibre_db.session
|
||||
.query(db.Books)
|
||||
.join(db.books_series_link)
|
||||
.join(db.Series)
|
||||
.filter(db.Series.id == series_id)
|
||||
.filter(db.Books.has_cover == 1)
|
||||
.first())
|
||||
return get_book_cover_internal(book, resolution=resolution)
|
||||
|
||||
|
||||
@ -854,13 +827,13 @@ def get_series_cover_internal(series_id, resolution=None):
|
||||
|
||||
|
||||
def get_series_thumbnail(series_id, resolution):
|
||||
return ub.session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES) \
|
||||
.filter(ub.Thumbnail.entity_id == series_id) \
|
||||
.filter(ub.Thumbnail.resolution == resolution) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||
.first()
|
||||
return (ub.session
|
||||
.query(ub.Thumbnail)
|
||||
.filter(ub.Thumbnail.type == THUMBNAIL_TYPE_SERIES)
|
||||
.filter(ub.Thumbnail.entity_id == series_id)
|
||||
.filter(ub.Thumbnail.resolution == resolution)
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
|
||||
.first())
|
||||
|
||||
|
||||
# saves book cover from url
|
||||
|
@ -27,10 +27,9 @@ import datetime
|
||||
import mimetypes
|
||||
from uuid import uuid4
|
||||
|
||||
# from babel.dates import format_date
|
||||
from flask import Blueprint, request, url_for
|
||||
from flask_babel import format_date
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
|
||||
from . import constants, logger
|
||||
|
||||
@ -115,7 +114,10 @@ def yesno(value, yes, no):
|
||||
@jinjia.app_template_filter('formatfloat')
|
||||
def formatfloat(value, decimals=1):
|
||||
value = 0 if not value else value
|
||||
return ('{0:.' + str(decimals) + 'f}').format(value).rstrip('0').rstrip('.')
|
||||
formated_value = ('{0:.' + str(decimals) + 'f}').format(value)
|
||||
if formated_value.endswith('.' + "0" * decimals):
|
||||
formated_value = formated_value.rstrip('0').rstrip('.')
|
||||
return formated_value
|
||||
|
||||
|
||||
@jinjia.app_template_filter('formatseriesindex')
|
||||
|
28
cps/kobo.py
28
cps/kobo.py
@ -18,7 +18,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
@ -36,7 +36,7 @@ from flask import (
|
||||
redirect,
|
||||
abort
|
||||
)
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
from werkzeug.datastructures import Headers
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.sql.expression import and_, or_
|
||||
@ -44,11 +44,10 @@ from sqlalchemy.exc import StatementError
|
||||
from sqlalchemy.sql import select
|
||||
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 .epub import get_epub_layout
|
||||
from .constants import COVER_THUMBNAIL_SMALL
|
||||
from .constants import COVER_THUMBNAIL_SMALL, COVER_THUMBNAIL_MEDIUM, COVER_THUMBNAIL_LARGE
|
||||
from .helper import get_download_link
|
||||
from .services import SyncToken as SyncToken
|
||||
from .web import download_required
|
||||
@ -132,7 +131,7 @@ def convert_to_kobo_timestamp_string(timestamp):
|
||||
return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
except AttributeError as exc:
|
||||
log.debug("Timestamp not valid: {}".format(exc))
|
||||
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
@kobo.route("/v1/library/sync")
|
||||
@ -151,15 +150,15 @@ def HandleSyncRequest():
|
||||
|
||||
# if no books synced don't respect sync_token
|
||||
if not ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.user_id == current_user.id).count():
|
||||
sync_token.books_last_modified = datetime.datetime.min
|
||||
sync_token.books_last_created = datetime.datetime.min
|
||||
sync_token.reading_state_last_modified = datetime.datetime.min
|
||||
sync_token.books_last_modified = datetime.min
|
||||
sync_token.books_last_created = datetime.min
|
||||
sync_token.reading_state_last_modified = datetime.min
|
||||
|
||||
new_books_last_modified = sync_token.books_last_modified # needed for sync selected shelfs only
|
||||
new_books_last_created = sync_token.books_last_created # needed to distinguish between new and changed entitlement
|
||||
new_reading_state_last_modified = sync_token.reading_state_last_modified
|
||||
|
||||
new_archived_last_modified = datetime.datetime.min
|
||||
new_archived_last_modified = datetime.min
|
||||
sync_results = []
|
||||
|
||||
# We reload the book database so that the user gets a fresh view of the library
|
||||
@ -376,7 +375,7 @@ def create_book_entitlement(book, archived):
|
||||
book_uuid = str(book.uuid)
|
||||
return {
|
||||
"Accessibility": "Full",
|
||||
"ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.datetime.utcnow())},
|
||||
"ActivePeriod": {"From": convert_to_kobo_timestamp_string(datetime.now(timezone.utc))},
|
||||
"Created": convert_to_kobo_timestamp_string(book.timestamp),
|
||||
"CrossRevisionId": book_uuid,
|
||||
"Id": book_uuid,
|
||||
@ -796,7 +795,7 @@ def HandleStateRequest(book_uuid):
|
||||
if new_book_read_status == ub.ReadBook.STATUS_IN_PROGRESS \
|
||||
and new_book_read_status != book_read.read_status:
|
||||
book_read.times_started_reading += 1
|
||||
book_read.last_time_started_reading = datetime.datetime.utcnow()
|
||||
book_read.last_time_started_reading = datetime.now(timezone.utc)
|
||||
book_read.read_status = new_book_read_status
|
||||
update_results_response["StatusInfoResult"] = {"Result": "Success"}
|
||||
except (KeyError, TypeError, ValueError, StatementError):
|
||||
@ -904,7 +903,12 @@ def get_current_bookmark_response(current_bookmark):
|
||||
@requires_kobo_auth
|
||||
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||
try:
|
||||
resolution = None if int(height) > 1000 else COVER_THUMBNAIL_SMALL
|
||||
if int(height) > 1000:
|
||||
resolution = COVER_THUMBNAIL_LARGE
|
||||
elif int(height) > 500:
|
||||
resolution = COVER_THUMBNAIL_MEDIUM
|
||||
else:
|
||||
resolution = COVER_THUMBNAIL_SMALL
|
||||
except ValueError:
|
||||
log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
|
||||
resolution = COVER_THUMBNAIL_SMALL
|
||||
|
@ -65,12 +65,14 @@ from os import urandom
|
||||
from functools import wraps
|
||||
|
||||
from flask import g, Blueprint, abort, request
|
||||
from flask_login import login_user, current_user, login_required
|
||||
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
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@ -78,7 +80,7 @@ kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
||||
|
||||
|
||||
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||
@login_required
|
||||
@user_login_required
|
||||
def generate_auth_token(user_id):
|
||||
warning = False
|
||||
host_list = request.host.rsplit(':')
|
||||
@ -120,7 +122,7 @@ def generate_auth_token(user_id):
|
||||
|
||||
|
||||
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def delete_auth_token(user_id):
|
||||
# Invalidate any previously generated Kobo Auth token for this user
|
||||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||
|
@ -17,11 +17,11 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
from . import ub
|
||||
import datetime
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.sql.expression import or_, and_, true
|
||||
from sqlalchemy import exc
|
||||
# from sqlalchemy import exc
|
||||
|
||||
|
||||
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
|
||||
@ -58,7 +58,7 @@ def change_archived_books(book_id, state=None, message=None):
|
||||
archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
|
||||
|
||||
archived_book.is_archived = state if state else not archived_book.is_archived
|
||||
archived_book.last_modified = datetime.datetime.utcnow() # toDo. Check utc timestamp
|
||||
archived_book.last_modified = datetime.now(timezone.utc) # toDo. Check utc timestamp
|
||||
|
||||
ub.session.merge(archived_book)
|
||||
ub.session_commit(message)
|
||||
|
@ -20,7 +20,6 @@ import sys
|
||||
|
||||
from . import create_app, limiter
|
||||
from .jinjia import jinjia
|
||||
from .remotelogin import remotelogin
|
||||
from flask import request
|
||||
|
||||
|
||||
@ -43,6 +42,7 @@ def main():
|
||||
from .shelf import shelf
|
||||
from .tasks_status import tasks
|
||||
from .error_handler import init_errorhandler
|
||||
from .remotelogin import remotelogin
|
||||
try:
|
||||
from .kobo import kobo, get_kobo_activated
|
||||
from .kobo_auth import kobo_auth
|
||||
|
@ -53,7 +53,6 @@ class Amazon(Metadata):
|
||||
def search(
|
||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||
) -> Optional[List[MetaRecord]]:
|
||||
#timer=time()
|
||||
def inner(link, index) -> [dict, int]:
|
||||
with self.session as session:
|
||||
try:
|
||||
@ -61,11 +60,11 @@ class Amazon(Metadata):
|
||||
r.raise_for_status()
|
||||
except Exception as ex:
|
||||
log.warning(ex)
|
||||
return None
|
||||
return []
|
||||
long_soup = BS(r.text, "lxml") #~4sec :/
|
||||
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
|
||||
if soup2 is None:
|
||||
return None
|
||||
return []
|
||||
try:
|
||||
match = MetaRecord(
|
||||
title = "",
|
||||
@ -88,7 +87,7 @@ class Amazon(Metadata):
|
||||
soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\
|
||||
.replace("\xa0"," ")[:-9].strip().strip("\n")
|
||||
except (AttributeError, TypeError):
|
||||
return None # if there is no description it is not a book and therefore should be ignored
|
||||
return [] # if there is no description it is not a book and therefore should be ignored
|
||||
try:
|
||||
match.title = soup2.find("span", attrs={"id": "productTitle"}).text
|
||||
except (AttributeError, TypeError):
|
||||
@ -113,7 +112,7 @@ class Amazon(Metadata):
|
||||
return match, index
|
||||
except Exception as e:
|
||||
log.error_or_exception(e)
|
||||
return None
|
||||
return []
|
||||
|
||||
val = list()
|
||||
if self.active:
|
||||
@ -134,6 +133,6 @@ class Amazon(Metadata):
|
||||
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])}
|
||||
val = list(map(lambda x : x.result() ,concurrent.futures.as_completed(fut)))
|
||||
val = list(map(lambda x : x.result(), concurrent.futures.as_completed(fut)))
|
||||
result = list(filter(lambda x: x, val))
|
||||
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance
|
||||
|
@ -54,7 +54,7 @@ class Google(Metadata):
|
||||
results.raise_for_status()
|
||||
except Exception as e:
|
||||
log.warning(e)
|
||||
return None
|
||||
return []
|
||||
for result in results.json().get("items", []):
|
||||
val.append(
|
||||
self._parse_search_result(
|
||||
|
@ -30,8 +30,9 @@ from flask_dance.consumer import oauth_authorized, oauth_error
|
||||
from flask_dance.contrib.github import make_github_blueprint, github
|
||||
from flask_dance.contrib.google import make_google_blueprint, google
|
||||
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
|
||||
from flask_login import login_user, current_user, login_required
|
||||
from .cw_login import login_user, current_user
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
from . import constants, logger, config, app, ub
|
||||
|
||||
@ -340,7 +341,7 @@ def github_login():
|
||||
|
||||
|
||||
@oauth.route('/unlink/github', methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def github_login_unlink():
|
||||
return unlink_oauth(oauthblueprints[0]['id'])
|
||||
|
||||
@ -364,6 +365,6 @@ def google_login():
|
||||
|
||||
|
||||
@oauth.route('/unlink/google', methods=["GET"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def google_login_unlink():
|
||||
return unlink_oauth(oauthblueprints[1]['id'])
|
||||
|
53
cps/opds.py
53
cps/opds.py
@ -25,14 +25,15 @@ import json
|
||||
from urllib.parse import unquote_plus
|
||||
|
||||
from flask import Blueprint, request, render_template, make_response, abort, Response, g
|
||||
from flask_login import current_user
|
||||
from flask_babel import get_locale
|
||||
from flask_babel import gettext as _
|
||||
|
||||
|
||||
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
|
||||
from . import logger, config, db, calibre_db, ub, isoLanguages, constants
|
||||
from .usermanagement import requires_basic_auth_if_no_ano
|
||||
from .usermanagement import requires_basic_auth_if_no_ano, auth
|
||||
from .helper import get_download_link, get_book_cover
|
||||
from .pagination import Pagination
|
||||
from .web import render_read_books
|
||||
@ -94,7 +95,7 @@ def feed_letter_books(book_id):
|
||||
@opds.route("/opds/new")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_new():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_RECENT):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_RECENT):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
@ -106,7 +107,7 @@ def feed_new():
|
||||
@opds.route("/opds/discover")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_discover():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_RANDOM):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_RANDOM):
|
||||
abort(404)
|
||||
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
|
||||
@ -117,7 +118,7 @@ def feed_discover():
|
||||
@opds.route("/opds/rated")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_best_rated():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_BEST_RATED):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_BEST_RATED):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||
@ -130,7 +131,7 @@ def feed_best_rated():
|
||||
@opds.route("/opds/hot")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_hot():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_HOT):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_HOT):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
|
||||
@ -154,7 +155,7 @@ def feed_hot():
|
||||
@opds.route("/opds/author")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_authorindex():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
|
||||
abort(404)
|
||||
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
|
||||
|
||||
@ -162,7 +163,7 @@ def feed_authorindex():
|
||||
@opds.route("/opds/author/letter/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_letter_author(book_id):
|
||||
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_AUTHOR):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
|
||||
@ -185,7 +186,7 @@ def feed_author(book_id):
|
||||
@opds.route("/opds/publisher")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_publisherindex():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_PUBLISHER):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_PUBLISHER):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries = calibre_db.session.query(db.Publishers)\
|
||||
@ -208,7 +209,7 @@ def feed_publisher(book_id):
|
||||
@opds.route("/opds/category")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_categoryindex():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
|
||||
abort(404)
|
||||
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
|
||||
|
||||
@ -216,7 +217,7 @@ def feed_categoryindex():
|
||||
@opds.route("/opds/category/letter/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_letter_category(book_id):
|
||||
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_CATEGORY):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
|
||||
@ -241,7 +242,7 @@ def feed_category(book_id):
|
||||
@opds.route("/opds/series")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_seriesindex():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
|
||||
abort(404)
|
||||
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
|
||||
|
||||
@ -249,7 +250,7 @@ def feed_seriesindex():
|
||||
@opds.route("/opds/series/letter/<book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_letter_series(book_id):
|
||||
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_SERIES):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
|
||||
@ -280,7 +281,7 @@ def feed_series(book_id):
|
||||
@opds.route("/opds/ratings")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_ratingindex():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_RATING):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_RATING):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
||||
@ -308,7 +309,7 @@ def feed_ratings(book_id):
|
||||
@opds.route("/opds/formats")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_formatindex():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_FORMAT):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_FORMAT):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
entries = calibre_db.session.query(db.Data).join(db.Books)\
|
||||
@ -339,14 +340,14 @@ def feed_format(book_id):
|
||||
@opds.route("/opds/language/")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_languagesindex():
|
||||
if not current_user.check_visibility(constants.SIDEBAR_LANGUAGE):
|
||||
if not auth.current_user().check_visibility(constants.SIDEBAR_LANGUAGE):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
if current_user.filter_language() == "all":
|
||||
if auth.current_user().filter_language() == "all":
|
||||
languages = calibre_db.speaking_language()
|
||||
else:
|
||||
languages = calibre_db.session.query(db.Languages).filter(
|
||||
db.Languages.lang_code == current_user.filter_language()).all()
|
||||
db.Languages.lang_code == auth.current_user().filter_language()).all()
|
||||
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
len(languages))
|
||||
@ -368,11 +369,11 @@ def feed_languages(book_id):
|
||||
@opds.route("/opds/shelfindex")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_shelfindex():
|
||||
if not (current_user.is_authenticated or g.allow_anonymous):
|
||||
if not (auth.current_user().is_authenticated or g.allow_anonymous):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
shelf = ub.session.query(ub.Shelf).filter(
|
||||
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
|
||||
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == auth.current_user().id)).order_by(ub.Shelf.name).all()
|
||||
number = len(shelf)
|
||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||
number)
|
||||
@ -382,14 +383,14 @@ def feed_shelfindex():
|
||||
@opds.route("/opds/shelf/<int:book_id>")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_shelf(book_id):
|
||||
if not (current_user.is_authenticated or g.allow_anonymous):
|
||||
if not (auth.current_user().is_authenticated or g.allow_anonymous):
|
||||
abort(404)
|
||||
off = request.args.get("offset") or 0
|
||||
if current_user.is_anonymous:
|
||||
if auth.current_user().is_anonymous:
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
|
||||
ub.Shelf.id == book_id).first()
|
||||
else:
|
||||
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
|
||||
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(auth.current_user().id),
|
||||
ub.Shelf.id == book_id),
|
||||
and_(ub.Shelf.is_public == 1,
|
||||
ub.Shelf.id == book_id))).first()
|
||||
@ -422,7 +423,7 @@ def feed_shelf(book_id):
|
||||
@opds.route("/opds/download/<book_id>/<book_format>/")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def opds_download_link(book_id, book_format):
|
||||
if not current_user.role_download():
|
||||
if not auth.current_user().role_download():
|
||||
return abort(403)
|
||||
if "Kobo" in request.headers.get('User-Agent'):
|
||||
client = "kobo"
|
||||
@ -468,7 +469,7 @@ def feed_get_cover(book_id):
|
||||
@opds.route("/opds/readbooks")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_read_books():
|
||||
if not (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
|
||||
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
|
||||
return abort(403)
|
||||
off = request.args.get("offset") or 0
|
||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
|
||||
@ -478,7 +479,7 @@ def feed_read_books():
|
||||
@opds.route("/opds/unreadbooks")
|
||||
@requires_basic_auth_if_no_ano
|
||||
def feed_unread_books():
|
||||
if not (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
|
||||
if not (auth.current_user().check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not auth.current_user().is_anonymous):
|
||||
return abort(403)
|
||||
off = request.args.get("offset") or 0
|
||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
|
||||
|
0
cps/redirect.py
Executable file → Normal file
0
cps/redirect.py
Executable file → Normal file
@ -25,12 +25,13 @@ from datetime import datetime
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
|
||||
from flask_login import login_required, current_user, login_user
|
||||
from .cw_login import login_user, current_user
|
||||
from flask_babel import gettext as _
|
||||
from sqlalchemy.sql.expression import true
|
||||
|
||||
from . import config, logger, ub
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
|
||||
remotelogin = Blueprint('remotelogin', __name__)
|
||||
@ -65,7 +66,7 @@ def remote_login():
|
||||
|
||||
@remotelogin.route('/verify/<token>')
|
||||
@remote_login_required
|
||||
@login_required
|
||||
@user_login_required
|
||||
def verify_token(token):
|
||||
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
|
||||
|
||||
|
@ -19,14 +19,13 @@
|
||||
from flask import render_template, g, abort, request
|
||||
from flask_babel import gettext as _
|
||||
from werkzeug.local import LocalProxy
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
from sqlalchemy.sql.expression import or_
|
||||
|
||||
from . import config, constants, logger, ub
|
||||
from .ub import User
|
||||
|
||||
|
||||
|
||||
log = logger.create()
|
||||
|
||||
def get_sidebar_config(kwargs=None):
|
||||
|
@ -21,7 +21,7 @@ import datetime
|
||||
from . import config, constants
|
||||
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
|
||||
from .tasks.database import TaskReconnectDatabase
|
||||
from .tasks.tempFolder import TaskDeleteTempFolder
|
||||
from .tasks.clean import TaskClean
|
||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||
from .services.worker import WorkerThread
|
||||
from .tasks.metadata_backup import TaskBackupMetadata
|
||||
@ -33,7 +33,7 @@ def get_scheduled_tasks(reconnect=True):
|
||||
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
||||
|
||||
# Delete temp folder
|
||||
tasks.append([lambda: TaskDeleteTempFolder(), 'delete temp', True])
|
||||
tasks.append([lambda: TaskClean(), 'delete temp', True])
|
||||
|
||||
# Generate metadata.opf file for each changed book
|
||||
if config.schedule_metadata_backup:
|
||||
@ -94,7 +94,7 @@ def register_startup_tasks():
|
||||
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
||||
else:
|
||||
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskDeleteTempFolder(), 'delete temp', True]])
|
||||
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskClean(), 'delete temp', True]])
|
||||
|
||||
|
||||
def should_task_be_running(start, duration):
|
||||
|
@ -19,17 +19,19 @@ from datetime import datetime
|
||||
|
||||
from flask import Blueprint, request, redirect, url_for, flash
|
||||
from flask import session as flask_session
|
||||
from flask_login import current_user
|
||||
from .cw_login import current_user
|
||||
from flask_babel import format_date
|
||||
from flask_babel import gettext as _
|
||||
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
|
||||
from sqlalchemy.sql.functions import coalesce
|
||||
|
||||
from . import logger, db, calibre_db, config, ub
|
||||
from .string_helper import strip_whitespaces
|
||||
from .usermanagement import login_required_if_no_ano
|
||||
from .render_template import render_title_template
|
||||
from .pagination import Pagination
|
||||
|
||||
|
||||
search = Blueprint('search', __name__)
|
||||
|
||||
log = logger.create()
|
||||
@ -80,16 +82,27 @@ def adv_search_custom_columns(cc, term, q):
|
||||
if custom_end:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
|
||||
elif c.datatype in ["int", "float"]:
|
||||
custom_low = term.get('custom_column_' + str(c.id) + '_low')
|
||||
custom_high = term.get('custom_column_' + str(c.id) + '_high')
|
||||
if custom_low:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value >= custom_low))
|
||||
if custom_high:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value <= custom_high))
|
||||
else:
|
||||
custom_query = term.get('custom_column_' + str(c.id))
|
||||
if custom_query != '' and custom_query is not None:
|
||||
if c.datatype == 'bool':
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == (custom_query == "True")))
|
||||
elif c.datatype == 'int' or c.datatype == 'float':
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == custom_query))
|
||||
elif c.datatype == 'rating':
|
||||
if c.datatype == 'bool':
|
||||
if custom_query != "Any":
|
||||
if custom_query == "":
|
||||
q = q.filter(~getattr(db.Books, 'custom_column_' + str(c.id)).
|
||||
any(db.cc_classes[c.id].value >= 0))
|
||||
else:
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == bool(custom_query == "True")))
|
||||
elif custom_query != '' and custom_query is not None:
|
||||
if c.datatype == 'rating':
|
||||
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
|
||||
else:
|
||||
@ -128,10 +141,10 @@ def adv_search_read_status(read_status):
|
||||
db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED
|
||||
else:
|
||||
try:
|
||||
if read_status == "True":
|
||||
db_filter = db.cc_classes[config.config_read_column].value == True
|
||||
if read_status == "":
|
||||
db_filter = coalesce(db.cc_classes[config.config_read_column].value, 2) == 2
|
||||
else:
|
||||
db_filter = coalesce(db.cc_classes[config.config_read_column].value, False) != True
|
||||
db_filter = db.cc_classes[config.config_read_column].value == bool(read_status == "True")
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
log.error("Custom Column No.{} does not exist in calibre database".format(config.config_read_column))
|
||||
flash(_("Custom Column No.%(column)d does not exist in calibre database",
|
||||
@ -254,11 +267,11 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||
description = term.get("comment")
|
||||
read_status = term.get("read_status")
|
||||
if author_name:
|
||||
author_name = author_name.strip().lower().replace(',', '|')
|
||||
author_name = strip_whitespaces(author_name).lower().replace(',', '|')
|
||||
if book_title:
|
||||
book_title = book_title.strip().lower()
|
||||
book_title = strip_whitespaces(book_title).lower()
|
||||
if publisher:
|
||||
publisher = publisher.strip().lower()
|
||||
publisher = strip_whitespaces(publisher).lower()
|
||||
|
||||
search_term = []
|
||||
cc_present = False
|
||||
@ -274,10 +287,23 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||
cc_present = True
|
||||
if column_end:
|
||||
search_term.extend(["{} <= {}".format(c.name,
|
||||
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
||||
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
||||
format='medium')
|
||||
)])
|
||||
cc_present = True
|
||||
if c.datatype in ["int", "float"]:
|
||||
column_low = term.get('custom_column_' + str(c.id) + '_low')
|
||||
column_high = term.get('custom_column_' + str(c.id) + '_high')
|
||||
if column_low:
|
||||
search_term.extend(["{} >= {}".format(c.name, column_low)])
|
||||
cc_present = True
|
||||
if column_high:
|
||||
search_term.extend(["{} <= {}".format(c.name,column_high)])
|
||||
cc_present = True
|
||||
elif c.datatype == "bool":
|
||||
if term.get('custom_column_' + str(c.id)) != "Any":
|
||||
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||
cc_present = True
|
||||
elif term.get('custom_column_' + str(c.id)):
|
||||
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||
cc_present = True
|
||||
|
@ -24,14 +24,14 @@ import os
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, Response, request, url_for
|
||||
from flask_login import current_user
|
||||
from flask_login import login_required
|
||||
from .cw_login import current_user
|
||||
from flask_babel import get_locale
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from cps.services.Metadata import Metadata
|
||||
from . import constants, logger, ub, web_server
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
# current_milli_time = lambda: int(round(time() * 1000))
|
||||
|
||||
@ -81,7 +81,7 @@ cl = list_classes(new_list)
|
||||
|
||||
|
||||
@meta.route("/metadata/provider")
|
||||
@login_required
|
||||
@user_login_required
|
||||
def metadata_provider():
|
||||
active = current_user.view_settings.get("metadata", {})
|
||||
provider = list()
|
||||
@ -95,7 +95,7 @@ def metadata_provider():
|
||||
|
||||
@meta.route("/metadata/provider", methods=["POST"])
|
||||
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def metadata_change_active_provider(prov_name):
|
||||
new_state = request.get_json()
|
||||
active = current_user.view_settings.get("metadata", {})
|
||||
@ -122,7 +122,7 @@ def metadata_change_active_provider(prov_name):
|
||||
|
||||
|
||||
@meta.route("/metadata/search", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def metadata_search():
|
||||
query = request.form.to_dict().get("query")
|
||||
data = list()
|
||||
|
35
cps/shelf.py
35
cps/shelf.py
@ -21,17 +21,17 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, flash, redirect, request, url_for, abort
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import current_user, login_required
|
||||
from .cw_login import current_user
|
||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||
from sqlalchemy.sql.expression import func, true
|
||||
|
||||
from . import calibre_db, config, db, logger, ub
|
||||
from .render_template import render_title_template
|
||||
from .usermanagement import login_required_if_no_ano
|
||||
from .usermanagement import login_required_if_no_ano, user_login_required
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@ -39,7 +39,7 @@ shelf = Blueprint('shelf', __name__)
|
||||
|
||||
|
||||
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def add_to_shelf(shelf_id, book_id):
|
||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
@ -80,7 +80,7 @@ def add_to_shelf(shelf_id, book_id):
|
||||
return "%s is a invalid Book Id. Could not be added to Shelf" % book_id, 400
|
||||
|
||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
|
||||
shelf.last_modified = datetime.utcnow()
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
@ -103,7 +103,7 @@ def add_to_shelf(shelf_id, book_id):
|
||||
|
||||
|
||||
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def search_to_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if shelf is None:
|
||||
@ -139,7 +139,7 @@ def search_to_shelf(shelf_id):
|
||||
for book in books_for_shelf:
|
||||
maxOrder += 1
|
||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
|
||||
shelf.last_modified = datetime.utcnow()
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.merge(shelf)
|
||||
ub.session.commit()
|
||||
@ -155,7 +155,7 @@ def search_to_shelf(shelf_id):
|
||||
|
||||
|
||||
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def remove_from_shelf(shelf_id, book_id):
|
||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
@ -185,7 +185,7 @@ def remove_from_shelf(shelf_id, book_id):
|
||||
|
||||
try:
|
||||
ub.session.delete(book_shelf)
|
||||
shelf.last_modified = datetime.utcnow()
|
||||
shelf.last_modified = datetime.now(timezone.utc)
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
ub.session.rollback()
|
||||
@ -212,14 +212,14 @@ def remove_from_shelf(shelf_id, book_id):
|
||||
|
||||
|
||||
@shelf.route("/shelf/create", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def create_shelf():
|
||||
shelf = ub.Shelf()
|
||||
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
|
||||
|
||||
|
||||
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def edit_shelf(shelf_id):
|
||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
if not check_shelf_edit_permissions(shelf):
|
||||
@ -229,7 +229,7 @@ def edit_shelf(shelf_id):
|
||||
|
||||
|
||||
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
def delete_shelf(shelf_id):
|
||||
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||
try:
|
||||
@ -259,7 +259,7 @@ def show_shelf(shelf_id, sort_param, page):
|
||||
|
||||
|
||||
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@user_login_required
|
||||
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):
|
||||
@ -271,7 +271,7 @@ def order_shelf(shelf_id):
|
||||
for book in books_in_shelf:
|
||||
setattr(book, 'order', to_save[str(book.book_id)])
|
||||
counter += 1
|
||||
# if order different from before -> shelf.last_modified = datetime.utcnow()
|
||||
# if order different from before -> shelf.last_modified = datetime.now(timezone.utc)
|
||||
try:
|
||||
ub.session.commit()
|
||||
except (OperationalError, InvalidRequestError) as e:
|
||||
@ -422,11 +422,14 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
# check user is allowed to access shelf
|
||||
if shelf and check_shelf_view_permissions(shelf):
|
||||
if shelf_type == 1:
|
||||
# order = [ub.BookShelf.order.asc()]
|
||||
if sort_param == 'pubnew':
|
||||
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
|
||||
if sort_param == 'pubold':
|
||||
change_shelf_order(shelf_id, [db.Books.pubdate])
|
||||
if sort_param == 'shelfnew':
|
||||
change_shelf_order(shelf_id, [ub.BookShelf.date_added.desc()])
|
||||
if sort_param == 'shelfold':
|
||||
change_shelf_order(shelf_id, [ub.BookShelf.date_added])
|
||||
if sort_param == 'abc':
|
||||
change_shelf_order(shelf_id, [db.Books.sort])
|
||||
if sort_param == 'zyx':
|
||||
@ -453,7 +456,7 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||
[ub.BookShelf.order.asc()],
|
||||
True, config.config_read_column,
|
||||
ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
|
||||
# delete chelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||
# delete shelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||
wrong_entries = calibre_db.session.query(ub.BookShelf) \
|
||||
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \
|
||||
.filter(db.Books.id == None).all()
|
||||
|
15
cps/static/css/libs/viewer.css
vendored
15
cps/static/css/libs/viewer.css
vendored
@ -1894,6 +1894,8 @@
|
||||
width:100%;
|
||||
height:100%;
|
||||
margin:0;
|
||||
top:0;
|
||||
left:0;
|
||||
}
|
||||
|
||||
.annotationEditorLayer :is(.freeTextEditor, .inkEditor, .stampEditor) > .resizers{
|
||||
@ -2646,6 +2648,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pdfViewer.copyAll{
|
||||
cursor:wait;
|
||||
}
|
||||
|
||||
.pdfViewer .canvasWrapper{
|
||||
overflow:hidden;
|
||||
width:100%;
|
||||
@ -3010,6 +3016,15 @@ body{
|
||||
scrollbar-color:var(--scrollbar-color) var(--scrollbar-bg-color);
|
||||
}
|
||||
|
||||
body.wait::before{
|
||||
content:"";
|
||||
position:fixed;
|
||||
width:100%;
|
||||
height:100%;
|
||||
z-index:100000;
|
||||
cursor:wait;
|
||||
}
|
||||
|
||||
.hidden,
|
||||
[hidden]{
|
||||
display:none !important;
|
||||
|
5979
cps/static/js/libs/pdf.mjs
vendored
5979
cps/static/js/libs/pdf.mjs
vendored
File diff suppressed because it is too large
Load Diff
5894
cps/static/js/libs/pdf.worker.mjs
vendored
5894
cps/static/js/libs/pdf.worker.mjs
vendored
File diff suppressed because one or more lines are too long
736
cps/static/js/libs/viewer.mjs
vendored
736
cps/static/js/libs/viewer.mjs
vendored
File diff suppressed because it is too large
Load Diff
@ -160,15 +160,21 @@ $(document).ready(function() {
|
||||
|
||||
$(".session").click(function() {
|
||||
window.sessionStorage.setItem("back", window.location.pathname);
|
||||
window.sessionStorage.setItem("search", window.location.search);
|
||||
});
|
||||
|
||||
$("#back").click(function() {
|
||||
var loc = sessionStorage.getItem("back");
|
||||
var param = sessionStorage.getItem("search");
|
||||
if (!loc) {
|
||||
loc = $(this).data("back");
|
||||
}
|
||||
sessionStorage.removeItem("back");
|
||||
window.location.href = loc;
|
||||
sessionStorage.removeItem("search");
|
||||
if (param === null) {
|
||||
param = "";
|
||||
}
|
||||
window.location.href = loc + param;
|
||||
|
||||
});
|
||||
|
||||
@ -606,6 +612,8 @@ $(function() {
|
||||
$("#auth_za").toggleClass("disabled");
|
||||
$("#pub_new").toggleClass("disabled");
|
||||
$("#pub_old").toggleClass("disabled");
|
||||
$("#shelf_new").toggleClass("disabled");
|
||||
$("#shelf_old").toggleClass("disabled");
|
||||
var alternative_text = $("#toggle_order_shelf").data('alt-text');
|
||||
$("#toggle_order_shelf").data('alt-text', $("#toggle_order_shelf").html());
|
||||
$("#toggle_order_shelf").html(alternative_text);
|
||||
|
@ -79,6 +79,6 @@ var reader;
|
||||
}
|
||||
|
||||
// Default settings load
|
||||
const theme = localStorage.getItem("calibre.reader.theme") ?? Object.keys(themes)[0];
|
||||
const theme = localStorage.getItem("calibre.reader.theme") ?? "lightTheme";
|
||||
selectTheme(theme);
|
||||
})();
|
||||
|
@ -49,12 +49,6 @@ pdfjs-download-button =
|
||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
||||
pdfjs-download-button-label = Pellgargañ
|
||||
pdfjs-bookmark-button-label = Pajenn a-vremañ
|
||||
# Used in Firefox for Android.
|
||||
pdfjs-open-in-app-button =
|
||||
.title = Digeriñ en arload
|
||||
# Used in Firefox for Android.
|
||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
||||
pdfjs-open-in-app-button-label = Digeriñ en arload
|
||||
|
||||
## Secondary toolbar and context menu
|
||||
|
||||
@ -214,6 +208,7 @@ pdfjs-find-next-button =
|
||||
pdfjs-find-next-button-label = War-lerc'h
|
||||
pdfjs-find-highlight-checkbox = Usskediñ pep tra
|
||||
pdfjs-find-match-case-checkbox-label = Teurel evezh ouzh ar pennlizherennoù
|
||||
pdfjs-find-match-diacritics-checkbox-label = Doujañ d’an tiredoù
|
||||
pdfjs-find-entire-word-checkbox-label = Gerioù a-bezh
|
||||
pdfjs-find-reached-top = Tizhet eo bet derou ar bajenn, kenderc'hel diouzh an diaz
|
||||
pdfjs-find-reached-bottom = Tizhet eo bet dibenn ar bajenn, kenderc'hel diouzh ar c'hrec'h
|
||||
@ -311,3 +306,7 @@ pdfjs-editor-alt-text-save-button = Enrollañ
|
||||
|
||||
## Color picker
|
||||
|
||||
|
||||
## Show all highlights
|
||||
## This is a toggle button to show/hide all the highlights.
|
||||
|
||||
|
@ -51,12 +51,6 @@ pdfjs-download-button-label = Sækja
|
||||
pdfjs-bookmark-button =
|
||||
.title = Núverandi síða (Skoða vefslóð frá núverandi síðu)
|
||||
pdfjs-bookmark-button-label = Núverandi síða
|
||||
# Used in Firefox for Android.
|
||||
pdfjs-open-in-app-button =
|
||||
.title = Opna í smáforriti
|
||||
# Used in Firefox for Android.
|
||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
||||
pdfjs-open-in-app-button-label = Opna í smáforriti
|
||||
|
||||
## Secondary toolbar and context menu
|
||||
|
||||
@ -284,7 +278,7 @@ pdfjs-text-annotation-type =
|
||||
|
||||
## Password
|
||||
|
||||
pdfjs-password-label = Sláðu inn lykilorð til að opna þessa PDF skrá.
|
||||
pdfjs-password-label = Settu inn lykilorð til að opna þessa PDF-skrá.
|
||||
pdfjs-password-invalid = Ógilt lykilorð. Reyndu aftur.
|
||||
pdfjs-password-ok-button = Í lagi
|
||||
pdfjs-password-cancel-button = Hætta við
|
||||
@ -304,8 +298,6 @@ pdfjs-editor-stamp-button-label = Bæta við eða breyta myndum
|
||||
pdfjs-editor-highlight-button =
|
||||
.title = Áherslulita
|
||||
pdfjs-editor-highlight-button-label = Áherslulita
|
||||
pdfjs-highlight-floating-button =
|
||||
.title = Áherslulita
|
||||
pdfjs-highlight-floating-button1 =
|
||||
.title = Áherslulita
|
||||
.aria-label = Áherslulita
|
||||
|
@ -279,7 +279,7 @@ pdfjs-text-annotation-type =
|
||||
## Password
|
||||
|
||||
pdfjs-password-label = この PDF ファイルを開くためのパスワードを入力してください。
|
||||
pdfjs-password-invalid = 無効なパスワードです。もう一度やり直してください。
|
||||
pdfjs-password-invalid = パスワードが正しくありません。もう一度試してください。
|
||||
pdfjs-password-ok-button = OK
|
||||
pdfjs-password-cancel-button = キャンセル
|
||||
pdfjs-web-fonts-disabled = ウェブフォントが無効になっています: 埋め込まれた PDF のフォントを使用できません。
|
||||
@ -298,8 +298,6 @@ pdfjs-editor-stamp-button-label = 画像を追加または編集
|
||||
pdfjs-editor-highlight-button =
|
||||
.title = 強調します
|
||||
pdfjs-editor-highlight-button-label = 強調
|
||||
pdfjs-highlight-floating-button =
|
||||
.title = 強調
|
||||
pdfjs-highlight-floating-button1 =
|
||||
.title = 強調
|
||||
.aria-label = 強調します
|
||||
|
@ -51,12 +51,6 @@ pdfjs-download-button-label = Sader
|
||||
pdfjs-bookmark-button =
|
||||
.title = Asebter amiran (Sken-d tansa URL seg usebter amiran)
|
||||
pdfjs-bookmark-button-label = Asebter amiran
|
||||
# Used in Firefox for Android.
|
||||
pdfjs-open-in-app-button =
|
||||
.title = Ldi deg usnas
|
||||
# Used in Firefox for Android.
|
||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
||||
pdfjs-open-in-app-button-label = Ldi deg usnas
|
||||
|
||||
## Secondary toolbar and context menu
|
||||
|
||||
@ -301,8 +295,27 @@ pdfjs-editor-ink-button-label = Suneɣ
|
||||
pdfjs-editor-stamp-button =
|
||||
.title = Rnu neɣ ẓreg tugniwin
|
||||
pdfjs-editor-stamp-button-label = Rnu neɣ ẓreg tugniwin
|
||||
pdfjs-editor-remove-button =
|
||||
.title = Kkes
|
||||
pdfjs-editor-highlight-button =
|
||||
.title = Derrer
|
||||
pdfjs-editor-highlight-button-label = Derrer
|
||||
pdfjs-highlight-floating-button1 =
|
||||
.title = Derrer
|
||||
.aria-label = Derrer
|
||||
pdfjs-highlight-floating-button-label = Derrer
|
||||
|
||||
## Remove button for the various kind of editor.
|
||||
|
||||
pdfjs-editor-remove-ink-button =
|
||||
.title = Kkes asuneɣ
|
||||
pdfjs-editor-remove-freetext-button =
|
||||
.title = Kkes aḍris
|
||||
pdfjs-editor-remove-stamp-button =
|
||||
.title = Kkes tugna
|
||||
pdfjs-editor-remove-highlight-button =
|
||||
.title = Kkes aderrer
|
||||
|
||||
##
|
||||
|
||||
# Editor Parameters
|
||||
pdfjs-editor-free-text-color-input = Initen
|
||||
pdfjs-editor-free-text-size-input = Teɣzi
|
||||
@ -312,6 +325,8 @@ pdfjs-editor-ink-opacity-input = Tebrek
|
||||
pdfjs-editor-stamp-add-image-button =
|
||||
.title = Rnu tawlaft
|
||||
pdfjs-editor-stamp-add-image-button-label = Rnu tawlaft
|
||||
# This refers to the thickness of the line used for free highlighting (not bound to text)
|
||||
pdfjs-editor-free-highlight-thickness-input = Tuzert
|
||||
pdfjs-free-text =
|
||||
.aria-label = Amaẓrag n uḍris
|
||||
pdfjs-free-text-default-content = Bdu tira...
|
||||
@ -335,3 +350,37 @@ pdfjs-editor-alt-text-decorative-tooltip = Yettwacreḍ d adlag
|
||||
## Editor resizers
|
||||
## This is used in an aria label to help to understand the role of the resizer.
|
||||
|
||||
pdfjs-editor-resizer-label-top-left = Tiɣmert n ufella n zelmeḍ — semsawi teɣzi
|
||||
pdfjs-editor-resizer-label-top-middle = Talemmat n ufella — semsawi teɣzi
|
||||
pdfjs-editor-resizer-label-top-right = Tiɣmert n ufella n yeffus — semsawi teɣzi
|
||||
pdfjs-editor-resizer-label-middle-right = Talemmast tayeffust — semsawi teɣzi
|
||||
pdfjs-editor-resizer-label-bottom-right = Tiɣmert n wadda n yeffus — semsawi teɣzi
|
||||
pdfjs-editor-resizer-label-bottom-middle = Talemmat n wadda — semsawi teɣzi
|
||||
pdfjs-editor-resizer-label-bottom-left = Tiɣmert n wadda n zelmeḍ — semsawi teɣzi
|
||||
pdfjs-editor-resizer-label-middle-left = Talemmast tazelmdaḍt — semsawi teɣzi
|
||||
|
||||
## Color picker
|
||||
|
||||
# This means "Color used to highlight text"
|
||||
pdfjs-editor-highlight-colorpicker-label = Ini n uderrer
|
||||
pdfjs-editor-colorpicker-button =
|
||||
.title = Senfel ini
|
||||
pdfjs-editor-colorpicker-dropdown =
|
||||
.aria-label = Afran n yiniten
|
||||
pdfjs-editor-colorpicker-yellow =
|
||||
.title = Awraɣ
|
||||
pdfjs-editor-colorpicker-green =
|
||||
.title = Azegzaw
|
||||
pdfjs-editor-colorpicker-blue =
|
||||
.title = Amidadi
|
||||
pdfjs-editor-colorpicker-pink =
|
||||
.title = Axuxi
|
||||
pdfjs-editor-colorpicker-red =
|
||||
.title = Azggaɣ
|
||||
|
||||
## Show all highlights
|
||||
## This is a toggle button to show/hide all the highlights.
|
||||
|
||||
pdfjs-editor-highlight-show-all-button-label = Sken akk
|
||||
pdfjs-editor-highlight-show-all-button =
|
||||
.title = Sken akk
|
||||
|
@ -51,12 +51,6 @@ pdfjs-download-button-label = Last ned
|
||||
pdfjs-bookmark-button =
|
||||
.title = Gjeldande side (sjå URL frå gjeldande side)
|
||||
pdfjs-bookmark-button-label = Gjeldande side
|
||||
# Used in Firefox for Android.
|
||||
pdfjs-open-in-app-button =
|
||||
.title = Opne i app
|
||||
# Used in Firefox for Android.
|
||||
# Length of the translation matters since we are in a mobile context, with limited screen estate.
|
||||
pdfjs-open-in-app-button-label = Opne i app
|
||||
|
||||
## Secondary toolbar and context menu
|
||||
|
||||
@ -301,9 +295,24 @@ pdfjs-editor-ink-button-label = Teikne
|
||||
pdfjs-editor-stamp-button =
|
||||
.title = Legg til eller rediger bilde
|
||||
pdfjs-editor-stamp-button-label = Legg til eller rediger bilde
|
||||
pdfjs-editor-highlight-button =
|
||||
.title = Markere
|
||||
pdfjs-editor-highlight-button-label = Markere
|
||||
pdfjs-highlight-floating-button1 =
|
||||
.title = Markere
|
||||
.aria-label = Markere
|
||||
pdfjs-highlight-floating-button-label = Markere
|
||||
|
||||
## Remove button for the various kind of editor.
|
||||
|
||||
pdfjs-editor-remove-ink-button =
|
||||
.title = Fjern teikninga
|
||||
pdfjs-editor-remove-freetext-button =
|
||||
.title = Fjern tekst
|
||||
pdfjs-editor-remove-stamp-button =
|
||||
.title = Fjern bildet
|
||||
pdfjs-editor-remove-highlight-button =
|
||||
.title = Fjern utheving
|
||||
|
||||
##
|
||||
|
||||
@ -316,6 +325,10 @@ pdfjs-editor-ink-opacity-input = Ugjennomskinleg
|
||||
pdfjs-editor-stamp-add-image-button =
|
||||
.title = Legg til bilde
|
||||
pdfjs-editor-stamp-add-image-button-label = Legg til bilde
|
||||
# This refers to the thickness of the line used for free highlighting (not bound to text)
|
||||
pdfjs-editor-free-highlight-thickness-input = Tjukkleik
|
||||
pdfjs-editor-free-highlight-thickness-title =
|
||||
.title = Endre tjukn når du markerer andre element enn tekst
|
||||
pdfjs-free-text =
|
||||
.aria-label = Tekstredigering
|
||||
pdfjs-free-text-default-content = Byrje å skrive…
|
||||
@ -345,9 +358,23 @@ pdfjs-editor-alt-text-textarea =
|
||||
## Editor resizers
|
||||
## This is used in an aria label to help to understand the role of the resizer.
|
||||
|
||||
pdfjs-editor-resizer-label-top-left = Øvste venstre hjørne – endre størrelse
|
||||
pdfjs-editor-resizer-label-top-middle = Øvst i midten — endre størrelse
|
||||
pdfjs-editor-resizer-label-top-right = Øvste høgre hjørne – endre størrelse
|
||||
pdfjs-editor-resizer-label-middle-right = Midt til høgre – endre størrelse
|
||||
pdfjs-editor-resizer-label-bottom-right = Nedste høgre hjørne – endre størrelse
|
||||
pdfjs-editor-resizer-label-bottom-middle = Nedst i midten — endre størrelse
|
||||
pdfjs-editor-resizer-label-bottom-left = Nedste venstre hjørne – endre størrelse
|
||||
pdfjs-editor-resizer-label-middle-left = Midt til venstre — endre størrelse
|
||||
|
||||
## Color picker
|
||||
|
||||
# This means "Color used to highlight text"
|
||||
pdfjs-editor-highlight-colorpicker-label = Uthevingsfarge
|
||||
pdfjs-editor-colorpicker-button =
|
||||
.title = Endre farge
|
||||
pdfjs-editor-colorpicker-dropdown =
|
||||
.aria-label = Fargeval
|
||||
pdfjs-editor-colorpicker-yellow =
|
||||
.title = Gul
|
||||
pdfjs-editor-colorpicker-green =
|
||||
@ -358,3 +385,10 @@ pdfjs-editor-colorpicker-pink =
|
||||
.title = Rosa
|
||||
pdfjs-editor-colorpicker-red =
|
||||
.title = Raud
|
||||
|
||||
## Show all highlights
|
||||
## This is a toggle button to show/hide all the highlights.
|
||||
|
||||
pdfjs-editor-highlight-show-all-button-label = Vis alle
|
||||
pdfjs-editor-highlight-show-all-button =
|
||||
.title = Vis alle
|
||||
|
@ -302,6 +302,10 @@ pdfjs-editor-stamp-button-label = Dodajanje ali urejanje slik
|
||||
pdfjs-editor-highlight-button =
|
||||
.title = Označevalnik
|
||||
pdfjs-editor-highlight-button-label = Označevalnik
|
||||
pdfjs-highlight-floating-button1 =
|
||||
.title = Označi
|
||||
.aria-label = Označi
|
||||
pdfjs-highlight-floating-button-label = Označi
|
||||
|
||||
## Remove button for the various kind of editor.
|
||||
|
||||
|
23
cps/string_helper.py
Normal file
23
cps/string_helper.py
Normal file
@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
|
||||
import re
|
||||
|
||||
|
||||
def strip_whitespaces(text):
|
||||
return re.sub("(^[\s\u200B-\u200D\ufeff]+)|([\s\u200B-\u200D\ufeff]+$)","", text)
|
||||
|
@ -16,31 +16,48 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from urllib.request import urlopen
|
||||
import datetime
|
||||
|
||||
from flask_babel import lazy_gettext as N_
|
||||
from sqlalchemy.sql.expression import or_
|
||||
|
||||
from cps import logger, file_helper
|
||||
from cps import logger, file_helper, ub
|
||||
from cps.services.worker import CalibreTask
|
||||
|
||||
|
||||
class TaskDeleteTempFolder(CalibreTask):
|
||||
class TaskClean(CalibreTask):
|
||||
def __init__(self, task_message=N_('Delete temp folder contents')):
|
||||
super(TaskDeleteTempFolder, self).__init__(task_message)
|
||||
super(TaskClean, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.app_db_session = ub.get_new_session_instance()
|
||||
|
||||
def run(self, worker_thread):
|
||||
# delete temp folder
|
||||
try:
|
||||
file_helper.del_temp_dir()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (PermissionError, OSError) as e:
|
||||
self.log.error("Error deleting temp folder: {}".format(e))
|
||||
# delete expired session keys
|
||||
self.log.debug("Deleted expired session_keys" )
|
||||
expiry = int(datetime.datetime.now().timestamp())
|
||||
try:
|
||||
self.app_db_session.query(ub.User_Sessions).filter(or_(ub.User_Sessions.expiry < expiry,
|
||||
ub.User_Sessions.expiry == None)).delete()
|
||||
self.app_db_session.commit()
|
||||
except Exception as ex:
|
||||
self.log.debug('Error deleting expired session keys: ' + str(ex))
|
||||
self._handleError('Error deleting expired session keys: ' + str(ex))
|
||||
self.app_db_session.rollback()
|
||||
return
|
||||
|
||||
self._handleSuccess()
|
||||
self.app_db_session.remove()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "Delete Temp Folder"
|
||||
return "Clean up"
|
||||
|
||||
@property
|
||||
def is_cancellable(self):
|
@ -39,6 +39,7 @@ from cps.file_helper import get_temp_dir
|
||||
from cps.tasks.mail import TaskEmail
|
||||
from cps import gdriveutils, helper
|
||||
from cps.constants import SUPPORTED_CALIBRE_BINARIES
|
||||
from cps.string_helper import strip_whitespaces
|
||||
|
||||
log = logger.create()
|
||||
|
||||
@ -107,7 +108,7 @@ class TaskConvert(CalibreTask):
|
||||
try:
|
||||
EmailText = N_(u"%(book)s send to E-Reader", book=escape(self.title))
|
||||
for email in self.ereader_mail.split(','):
|
||||
email = email.strip()
|
||||
email = strip_whitespaces(email)
|
||||
worker_thread.add(self.user, TaskEmail(self.settings['subject'],
|
||||
self.results["path"],
|
||||
filename,
|
||||
@ -255,7 +256,7 @@ class TaskConvert(CalibreTask):
|
||||
try:
|
||||
# path_tmp_opf = self._embed_metadata()
|
||||
if config.config_embed_metadata:
|
||||
quotes = [3, 5]
|
||||
quotes = [5]
|
||||
tmp_dir = get_temp_dir()
|
||||
calibredb_binarypath = os.path.join(config.config_binariesdir, SUPPORTED_CALIBRE_BINARIES["calibredb"])
|
||||
my_env = os.environ.copy()
|
||||
|
@ -16,11 +16,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from urllib.request import urlopen
|
||||
|
||||
from flask_babel import lazy_gettext as N_
|
||||
|
||||
from cps import config, logger
|
||||
from cps import config, logger, db, ub
|
||||
from cps.services.worker import CalibreTask
|
||||
|
||||
|
||||
@ -28,18 +26,12 @@ class TaskReconnectDatabase(CalibreTask):
|
||||
def __init__(self, task_message=N_('Reconnecting Calibre database')):
|
||||
super(TaskReconnectDatabase, self).__init__(task_message)
|
||||
self.log = logger.create()
|
||||
self.listen_address = config.get_config_ipaddress()
|
||||
self.listen_port = config.config_port
|
||||
self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
|
||||
def run(self, worker_thread):
|
||||
address = self.listen_address if self.listen_address else 'localhost'
|
||||
port = self.listen_port if self.listen_port else 8083
|
||||
|
||||
try:
|
||||
urlopen('http://' + address + ':' + str(port) + '/reconnect')
|
||||
self._handleSuccess()
|
||||
except Exception as ex:
|
||||
self._handleError('Unable to reconnect Calibre database: ' + str(ex))
|
||||
self.calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||
self.calibre_db.session.close()
|
||||
self._handleSuccess()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
@ -34,6 +34,7 @@ from cps.services import gmail
|
||||
from cps.embed_helper import do_calibre_export
|
||||
from cps import logger, config
|
||||
from cps import gdriveutils
|
||||
from cps.string_helper import strip_whitespaces
|
||||
import uuid
|
||||
|
||||
log = logger.create()
|
||||
@ -127,9 +128,9 @@ class TaskEmail(CalibreTask):
|
||||
try:
|
||||
# Parse out the address from the From line, and then the domain from that
|
||||
from_email = parseaddr(self.settings["mail_from"])[1]
|
||||
msgid_domain = from_email.partition('@')[2].strip()
|
||||
msgid_domain = strip_whitespaces(from_email.partition('@')[2])
|
||||
# This can sometimes sneak through parseaddr if the input is malformed
|
||||
msgid_domain = msgid_domain.rstrip('>').strip()
|
||||
msgid_domain = strip_whitespaces(msgid_domain.rstrip('>'))
|
||||
except Exception:
|
||||
msgid_domain = ''
|
||||
return msgid_domain or 'calibre-web.com'
|
||||
|
@ -20,14 +20,13 @@ import os
|
||||
from shutil import copyfile, copyfileobj
|
||||
from urllib.request import urlopen
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .. import constants
|
||||
from cps import config, db, fs, gdriveutils, logger, ub
|
||||
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func, text, or_
|
||||
from flask_babel import lazy_gettext as N_
|
||||
|
||||
try:
|
||||
from wand.image import Image
|
||||
use_IM = True
|
||||
@ -36,7 +35,7 @@ except (ImportError, RuntimeError) as e:
|
||||
|
||||
|
||||
def get_resize_height(resolution):
|
||||
return int(225 * resolution)
|
||||
return int(255 * resolution)
|
||||
|
||||
|
||||
def get_resize_width(resolution, original_width, original_height):
|
||||
@ -70,11 +69,11 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
self.log = logger.create()
|
||||
self.book_id = book_id
|
||||
self.app_db_session = ub.get_new_session_instance()
|
||||
# self.calibre_db = db.CalibreDB(expire_on_commit=False, init=True)
|
||||
self.cache = fs.FileSystem()
|
||||
self.resolutions = [
|
||||
constants.COVER_THUMBNAIL_SMALL,
|
||||
constants.COVER_THUMBNAIL_MEDIUM
|
||||
constants.COVER_THUMBNAIL_MEDIUM,
|
||||
constants.COVER_THUMBNAIL_LARGE
|
||||
]
|
||||
|
||||
def run(self, worker_thread):
|
||||
@ -124,7 +123,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_COVER) \
|
||||
.filter(ub.Thumbnail.entity_id == book_id) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc))) \
|
||||
.all()
|
||||
|
||||
def create_book_cover_thumbnails(self, book):
|
||||
@ -166,7 +165,7 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
self.app_db_session.rollback()
|
||||
|
||||
def update_book_cover_thumbnail(self, book, thumbnail):
|
||||
thumbnail.generated_at = datetime.utcnow()
|
||||
thumbnail.generated_at = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
self.app_db_session.commit()
|
||||
@ -198,7 +197,8 @@ class TaskGenerateCoverThumbnails(CalibreTask):
|
||||
img.format = thumbnail.format
|
||||
img.save(filename=filename)
|
||||
else:
|
||||
with open(filename, 'rb') as fd:
|
||||
stream.seek(0)
|
||||
with open(filename, 'wb') as fd:
|
||||
copyfileobj(stream, fd)
|
||||
|
||||
except Exception as ex:
|
||||
@ -323,12 +323,12 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
|
||||
.all()
|
||||
|
||||
def get_series_thumbnails(self, series_id):
|
||||
return self.app_db_session \
|
||||
.query(ub.Thumbnail) \
|
||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES) \
|
||||
.filter(ub.Thumbnail.entity_id == series_id) \
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow())) \
|
||||
.all()
|
||||
return (self.app_db_session
|
||||
.query(ub.Thumbnail)
|
||||
.filter(ub.Thumbnail.type == constants.THUMBNAIL_TYPE_SERIES)
|
||||
.filter(ub.Thumbnail.entity_id == series_id)
|
||||
.filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.now(timezone.utc)))
|
||||
.all())
|
||||
|
||||
def create_series_thumbnail(self, series, series_books, resolution):
|
||||
thumbnail = ub.Thumbnail()
|
||||
@ -347,7 +347,7 @@ class TaskGenerateSeriesThumbnails(CalibreTask):
|
||||
self.app_db_session.rollback()
|
||||
|
||||
def update_series_thumbnail(self, series_books, thumbnail):
|
||||
thumbnail.generated_at = datetime.utcnow()
|
||||
thumbnail.generated_at = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
self.app_db_session.commit()
|
||||
|
@ -17,7 +17,7 @@
|
||||
from markupsafe import escape
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from .cw_login import current_user
|
||||
from flask_babel import gettext as _
|
||||
from flask_babel import format_datetime
|
||||
from babel.units import format_unit
|
||||
@ -26,6 +26,7 @@ from . import logger
|
||||
from .render_template import render_title_template
|
||||
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \
|
||||
STAT_CANCELLED
|
||||
from .usermanagement import user_login_required
|
||||
|
||||
tasks = Blueprint('tasks', __name__)
|
||||
|
||||
@ -33,14 +34,14 @@ log = logger.create()
|
||||
|
||||
|
||||
@tasks.route("/ajax/emailstat")
|
||||
@login_required
|
||||
@user_login_required
|
||||
def get_email_status_json():
|
||||
tasks = WorkerThread.get_instance().tasks
|
||||
return jsonify(render_task_status(tasks))
|
||||
|
||||
|
||||
@tasks.route("/tasks")
|
||||
@login_required
|
||||
@user_login_required
|
||||
def get_tasks_status():
|
||||
# if current user admin, show all email, otherwise only own emails
|
||||
return render_title_template('tasks.html', title=_("Tasks"), page="tasks")
|
||||
@ -81,6 +82,7 @@ def render_task_status(tasklist):
|
||||
ret['task_id'] = task.id
|
||||
ret['stat'] = task.stat
|
||||
ret['is_cancellable'] = task.is_cancellable
|
||||
ret['error'] = task.error
|
||||
|
||||
rendered_tasklist.append(ret)
|
||||
|
||||
|
@ -20,7 +20,7 @@
|
||||
{{ _('Download') }} :
|
||||
</button>
|
||||
{% for format in entry.data %}
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower) }}"
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}"
|
||||
id="btnGroupDrop1{{ format.format|lower }}" class="btn btn-primary"
|
||||
role="button">
|
||||
<span class="glyphicon glyphicon-download"></span>{{ format.format }}
|
||||
@ -36,7 +36,7 @@
|
||||
<ul class="dropdown-menu" aria-labelledby="btnGroupDrop1">
|
||||
{% for format in entry.data %}
|
||||
<li>
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower) }}">{{ format.format }}
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.id, book_format=format.format|lower, anyname=entry.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}">{{ format.format }}
|
||||
({{ format.uncompressed_size|filesizeformat }})</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"input": {
|
||||
"placeholder": "a placeholder"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"page1": "Page One",
|
||||
"page2": "Page Two"
|
||||
}
|
||||
}
|
@ -158,21 +158,41 @@
|
||||
{% if cc|length > 0 %}
|
||||
{% for c in cc %}
|
||||
<div class="form-group">
|
||||
<!--input type="number" step="1" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value=""-->
|
||||
<label for="{{ 'custom_column_' ~ c.id }}">{{ c.name }}</label>
|
||||
{% if c.datatype == 'bool' %}
|
||||
<select name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" class="form-control">
|
||||
<option value="" selected></option>
|
||||
<option value="Any" selected>{{_('Any')}}</option>
|
||||
<option value="">{{_('Empty')}}</option>
|
||||
<option value="True" >{{_('Yes')}}</option>
|
||||
<option value="False" >{{_('No')}}</option>
|
||||
</select>
|
||||
{% endif %}
|
||||
|
||||
{% if c.datatype == 'int' %}
|
||||
<input type="number" step="1" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value="">
|
||||
<div class="row">
|
||||
<div class="form-group col-sm-6">
|
||||
<label for="{{ 'custom_column_' ~ c.id }}_low">{{_('From:')}}</label>
|
||||
<input type="number" step="1" class="form-control" name="{{ 'custom_column_' ~ c.id }}_low" id="{{ 'custom_column_' ~ c.id }}_low" value="">
|
||||
</div>
|
||||
<div class="form-group col-sm-6">
|
||||
<label for="{{ 'custom_column_' ~ c.id }}_high">{{_('To:')}}</label>
|
||||
<input type="number" step="1" class="form-control" name="{{ 'custom_column_' ~ c.id }}_high" id="{{ 'custom_column_' ~ c.id }}_high" value="">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if c.datatype == 'float' %}
|
||||
<input type="number" step="0.01" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value="">
|
||||
<div class="row">
|
||||
<div class="form-group col-sm-6">
|
||||
<label for="{{ 'custom_column_' ~ c.id }}_low">{{_('From:')}}</label>
|
||||
<input type="number" step="0.01" class="form-control" name="{{ 'custom_column_' ~ c.id }}_low" id="{{ 'custom_column_' ~ c.id }}_low" value="">
|
||||
</div>
|
||||
<div class="form-group col-sm-6">
|
||||
<label for="{{ 'custom_column_' ~ c.id }}_high">{{_('To:')}}</label>
|
||||
<input type="number" step="0.01" class="form-control" name="{{ 'custom_column_' ~ c.id }}_high" id="{{ 'custom_column_' ~ c.id }}_high" value="">
|
||||
</div>
|
||||
</div>
|
||||
<!--input type="number" step="0.01" class="form-control" name="{{ 'custom_column_' ~ c.id }}" id="{{ 'custom_column_' ~ c.id }}" value=""-->
|
||||
{% endif %}
|
||||
|
||||
{% if c.datatype == 'datetime' %}
|
||||
|
@ -25,6 +25,8 @@
|
||||
<a data-toggle="tooltip" title="{{_('Sort authors in reverse alphabetical order')}}" id="auth_za" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='authza')}}"><span class="glyphicon glyphicon-user"></span><span class="glyphicon glyphicon-sort-by-alphabet-alt"></span></a>
|
||||
<a data-toggle="tooltip" title="{{_('Sort according to publishing date, newest first')}}" id="pub_new" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='pubnew')}}"><span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order"></span></a>
|
||||
<a data-toggle="tooltip" title="{{_('Sort according to publishing date, oldest first')}}" id="pub_old" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='pubold')}}"><span class="glyphicon glyphicon-calendar"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a>
|
||||
<a data-toggle="tooltip" title="{{_('Sort according to book added to shelf, newest first')}}" id="shelf_new" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='shelfnew')}}"><span class="glyphicon glyphicon-list"></span><span class="glyphicon glyphicon-sort-by-order"></span></a>
|
||||
<a data-toggle="tooltip" title="{{_('Sort according to book added to shelf, oldest first')}}" id="shelf_old" class="btn btn-primary disabled" href="{{url_for('shelf.show_shelf', shelf_id=shelf.id, sort_param='shelfold')}}"><span class="glyphicon glyphicon-list"></span><span class="glyphicon glyphicon-sort-by-order-alt"></span></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
@ -55,7 +55,7 @@
|
||||
{% if entry.Books.data|length %}
|
||||
<div class="btn-group" role="group">
|
||||
{% for format in entry.Books.data %}
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.Books.id, book_format=format.format|lower, anyname=entry.Books.id|string+'.'+format.format|lower) }}" id="btnGroupDrop{{entry.Books.id}}{{format.format|lower}}" class="btn btn-primary" role="button">
|
||||
<a href="{{ url_for('web.download_link', book_id=entry.Books.id, book_format=format.format|lower, anyname=entry.Books.id|string+'.'+format.format|lower|replace('kepub', 'kepub.epub')) }}" id="btnGroupDrop{{entry.Books.id}}{{format.format|lower}}" class="btn btn-primary" role="button">
|
||||
<span class="glyphicon glyphicon-download"></span>{{format.format}} ({{ format.uncompressed_size|filesizeformat }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
@ -16,6 +16,7 @@
|
||||
<th data-halign="right" data-align="right" data-field="progress" data-sortable="true" data-sorter="elementSorter">{{_('Progress')}}</th>
|
||||
<th data-halign="right" data-align="right" data-field="runtime" data-sortable="true" data-sort-name="rt">{{_('Run Time')}}</th>
|
||||
<th data-halign="right" data-align="right" data-field="starttime" data-sortable="true" data-sort-name="id">{{_('Start Time')}}</th>
|
||||
<th data-halign="right" data-align="right" data-field="error" data-sortable="true">{{_('Message')}}</th>
|
||||
{% if current_user.role_admin() %}
|
||||
<th data-halign="right" data-align="right" data-formatter="TaskActions" data-switchable="false">{{_('Actions')}}</th>
|
||||
{% endif %}
|
||||
|
@ -25,7 +25,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="kindle_mail">{{_('Send to eReader Email Address. Use comma to seperate emails for multiple eReaders')}}</label>
|
||||
<label for="kindle_mail">{{_('Send to eReader Email Address. Use comma to separate emails for multiple eReaders')}}</label>
|
||||
<input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}">
|
||||
</div>
|
||||
{% if not content.role_anonymous() %}
|
||||
|
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.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user