Merge branch 'Develop'

This commit is contained in:
Ozzie Isaacs 2023-02-27 18:54:32 +01:00
commit 942bcff5c4
52 changed files with 3944 additions and 733 deletions

1
.gitignore vendored
View File

@ -34,3 +34,4 @@ settings.yaml
gdrive_credentials gdrive_credentials
client_secrets.json client_secrets.json
gmail.json gmail.json
/.key

2
cps.py
View File

@ -21,7 +21,7 @@ import os
import sys import sys
# Add local path to sys.path so we can import cps # Add local path to sys.path, so we can import cps
path = os.path.dirname(os.path.abspath(__file__)) path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path) sys.path.insert(0, path)

View File

@ -41,6 +41,11 @@ from . import config_sql
from . import cache_buster from . import cache_buster
from . import ub, db from . import ub, db
try:
from flask_limiter import Limiter
limiter_present = True
except ImportError:
limiter_present = False
try: try:
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
wtf_present = True wtf_present = True
@ -81,7 +86,7 @@ app.config.update(
lm = MyLoginManager() lm = MyLoginManager()
config = config_sql._ConfigSQL() config = config_sql.ConfigSQL()
cli_param = CliParameter() cli_param = CliParameter()
@ -96,33 +101,36 @@ web_server = WebServer()
updater_thread = Updater() updater_thread = Updater()
if limiter_present:
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=True)
else:
limiter = None
def create_app(): def create_app():
lm.login_view = 'web.login'
lm.anonymous_user = ub.Anonymous
lm.session_protection = 'strong'
if csrf: if csrf:
csrf.init_app(app) csrf.init_app(app)
cli_param.init() cli_param.init()
ub.init_db(cli_param.settings_path, cli_param.user_credentials) ub.init_db(cli_param.settings_path)
# pylint: disable=no-member # pylint: disable=no-member
config_sql.load_configuration(config, ub.session, cli_param) encrypt_key, error = config_sql.get_encryption_key(os.path.dirname(cli_param.settings_path))
db.CalibreDB.update_config(config) config_sql.load_configuration(ub.session, encrypt_key)
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path) config.init_config(ub.session, encrypt_key, cli_param)
calibre_db.init_db()
updater_thread.init_updater(config, web_server) if error:
# Perform dry run of updater and exit afterwards log.error(error)
if cli_param.dry_run:
updater_thread.dry_run()
sys.exit(0)
updater_thread.start()
ub.password_change(cli_param.user_credentials)
if not limiter:
log.info('*** "flask-limiter" is needed for calibre-web to run. '
'Please install it using pip: "pip install flask-limiter" ***')
print('*** "flask-limiter" is needed for calibre-web to run. '
'Please install it using pip: "pip install flask-limiter" ***')
web_server.stop(True)
sys.exit(8)
if sys.version_info < (3, 0): if sys.version_info < (3, 0):
log.info( log.info(
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, ' '*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
@ -139,6 +147,22 @@ def create_app():
'Please install it using pip: "pip install flask-WTF" ***') 'Please install it using pip: "pip install flask-WTF" ***')
web_server.stop(True) web_server.stop(True)
sys.exit(7) sys.exit(7)
lm.login_view = 'web.login'
lm.anonymous_user = ub.Anonymous
lm.session_protection = 'strong' if config.config_session == 1 else "basic"
db.CalibreDB.update_config(config)
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
calibre_db.init_db()
updater_thread.init_updater(config, web_server)
# Perform dry run of updater and exit afterwards
if cli_param.dry_run:
updater_thread.dry_run()
sys.exit(0)
updater_thread.start()
for res in dependency_check() + dependency_check(True): for res in dependency_check() + dependency_check(True):
log.info('*** "{}" version does not meet the requirements. ' log.info('*** "{}" version does not meet the requirements. '
'Should: {}, Found: {}, please consider installing required version ***' 'Should: {}, Found: {}, please consider installing required version ***'
@ -150,7 +174,6 @@ def create_app():
if os.environ.get('FLASK_DEBUG'): if os.environ.get('FLASK_DEBUG'):
cache_buster.init_cache_busting(app) cache_buster.init_cache_busting(app)
log.info('Starting Calibre Web...') log.info('Starting Calibre Web...')
Principal(app) Principal(app)
lm.init_app(app) lm.init_app(app)
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session)) app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
@ -168,9 +191,13 @@ def create_app():
services.ldap.init_app(app, config) services.ldap.init_app(app, config)
if services.goodreads_support: if services.goodreads_support:
services.goodreads_support.connect(config.config_goodreads_api_key, services.goodreads_support.connect(config.config_goodreads_api_key,
config.config_goodreads_api_secret, config.config_goodreads_api_secret_e,
config.config_use_goodreads) config.config_use_goodreads)
config.store_calibre_uuid(calibre_db, db.Library_Id) config.store_calibre_uuid(calibre_db, db.Library_Id)
# Configure rate limiter
app.config.update(RATELIMIT_ENABLED=config.config_ratelimiter)
limiter.init_app(app)
# Register scheduled tasks # Register scheduled tasks
from .schedule import register_scheduled_tasks, register_startup_tasks from .schedule import register_scheduled_tasks, register_startup_tasks
register_scheduled_tasks(config.schedule_reconnect) register_scheduled_tasks(config.schedule_reconnect)

View File

@ -22,7 +22,6 @@
import os import os
import re import re
import base64
import json import json
import operator import operator
import time import time
@ -104,7 +103,6 @@ def before_request():
if not ub.check_user_session(current_user.id, flask_session.get('_id')) and 'opds' not in request.path: if not ub.check_user_session(current_user.id, flask_session.get('_id')) and 'opds' not in request.path:
logout_user() logout_user()
g.constants = constants g.constants = constants
# g.user = current_user
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION','') g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION','')
g.allow_registration = config.config_public_reg g.allow_registration = config.config_public_reg
g.allow_anonymous = config.config_anonbrowse g.allow_anonymous = config.config_anonbrowse
@ -116,6 +114,7 @@ def before_request():
'admin.simulatedbchange', 'admin.simulatedbchange',
'admin.db_configuration', 'admin.db_configuration',
'web.login', 'web.login',
'web.login_post',
'web.logout', 'web.logout',
'admin.load_dialogtexts', 'admin.load_dialogtexts',
'admin.ajax_pathchooser'): 'admin.ajax_pathchooser'):
@ -214,12 +213,12 @@ def admin():
commit = version['version'] commit = version['version']
all_user = ub.session.query(ub.User).all() all_user = ub.session.query(ub.User).all()
email_settings = config.get_mail_settings() # email_settings = mail_config.get_mail_settings()
schedule_time = format_time(datetime_time(hour=config.schedule_start_time), format="short") schedule_time = format_time(datetime_time(hour=config.schedule_start_time), format="short")
t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60) t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60)
schedule_duration = format_timedelta(t, threshold=.99) schedule_duration = format_timedelta(t, threshold=.99)
return render_title_template("admin.html", allUser=all_user, email=email_settings, config=config, commit=commit, return render_title_template("admin.html", allUser=all_user, config=config, commit=commit,
feature_support=feature_support, schedule_time=schedule_time, feature_support=feature_support, schedule_time=schedule_time,
schedule_duration=schedule_duration, schedule_duration=schedule_duration,
title=_("Admin page"), page="admin") title=_("Admin page"), page="admin")
@ -1084,7 +1083,7 @@ def _config_checkbox_int(to_save, x):
def _config_string(to_save, x): def _config_string(to_save, x):
return config.set_from_dictionary(to_save, x, lambda y: y.strip() if y else y) return config.set_from_dictionary(to_save, x, lambda y: y.strip().strip(u'\u200B\u200C\u200D\ufeff') if y else y)
def _configuration_gdrive_helper(to_save): def _configuration_gdrive_helper(to_save):
@ -1173,9 +1172,9 @@ def _configuration_ldap_helper(to_save):
reboot_required |= _config_string(to_save, "config_ldap_cert_path") reboot_required |= _config_string(to_save, "config_ldap_cert_path")
reboot_required |= _config_string(to_save, "config_ldap_key_path") reboot_required |= _config_string(to_save, "config_ldap_key_path")
_config_string(to_save, "config_ldap_group_name") _config_string(to_save, "config_ldap_group_name")
if to_save.get("config_ldap_serv_password", "") != "": if to_save.get("config_ldap_serv_password_e", "") != "":
reboot_required |= 1 reboot_required |= 1
config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') config.set_from_dictionary(to_save, "config_ldap_serv_password_e")
config.save() config.save()
if not config.config_ldap_provider_url \ if not config.config_ldap_provider_url \
@ -1187,7 +1186,7 @@ def _configuration_ldap_helper(to_save):
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS: if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE: if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password): if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password_e):
return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account and Password')) return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account and Password'))
else: else:
if not config.config_ldap_serv_username: if not config.config_ldap_serv_username:
@ -1255,7 +1254,7 @@ def new_user():
kobo_support=kobo_support, registered_oauth=oauth_check) kobo_support=kobo_support, registered_oauth=oauth_check)
@admi.route("/admin/mailsettings") @admi.route("/admin/mailsettings", methods=["GET"])
@login_required @login_required
@admin_required @admin_required
def edit_mailsettings(): def edit_mailsettings():
@ -1288,7 +1287,7 @@ def update_mailsettings():
else: else:
_config_int(to_save, "mail_port") _config_int(to_save, "mail_port")
_config_int(to_save, "mail_use_ssl") _config_int(to_save, "mail_use_ssl")
_config_string(to_save, "mail_password") _config_string(to_save, "mail_password_e")
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024) _config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
config.mail_server = to_save.get('mail_server', "").strip() config.mail_server = to_save.get('mail_server', "").strip()
config.mail_from = to_save.get('mail_from', "").strip() config.mail_from = to_save.get('mail_from', "").strip()
@ -1771,10 +1770,10 @@ def _configuration_update_helper():
# Goodreads configuration # Goodreads configuration
_config_checkbox(to_save, "config_use_goodreads") _config_checkbox(to_save, "config_use_goodreads")
_config_string(to_save, "config_goodreads_api_key") _config_string(to_save, "config_goodreads_api_key")
_config_string(to_save, "config_goodreads_api_secret") _config_string(to_save, "config_goodreads_api_secret_e")
if services.goodreads_support: if services.goodreads_support:
services.goodreads_support.connect(config.config_goodreads_api_key, services.goodreads_support.connect(config.config_goodreads_api_key,
config.config_goodreads_api_secret, config.config_goodreads_api_secret_e,
config.config_use_goodreads) config.config_use_goodreads)
_config_int(to_save, "config_updatechannel") _config_int(to_save, "config_updatechannel")
@ -1787,10 +1786,25 @@ def _configuration_update_helper():
if config.config_login_type == constants.LOGIN_OAUTH: if config.config_login_type == constants.LOGIN_OAUTH:
reboot_required |= _configuration_oauth_helper(to_save) reboot_required |= _configuration_oauth_helper(to_save)
# logfile configuration
reboot, message = _configuration_logfile_helper(to_save) reboot, message = _configuration_logfile_helper(to_save)
if message: if message:
return message return message
reboot_required |= reboot reboot_required |= reboot
# security configuration
_config_checkbox(to_save, "config_password_policy")
_config_checkbox(to_save, "config_password_number")
_config_checkbox(to_save, "config_password_lower")
_config_checkbox(to_save, "config_password_upper")
_config_checkbox(to_save, "config_password_special")
if 0 < int(to_save.get("config_password_min_length", "0")) < 41:
_config_int(to_save, "config_password_min_length")
else:
return _configuration_result(_('Password length has to be between 1 and 40'))
reboot_required |= _config_int(to_save, "config_session")
reboot_required |= _config_checkbox(to_save, "config_ratelimiter")
# Rarfile Content configuration # Rarfile Content configuration
_config_string(to_save, "config_rarfile_location") _config_string(to_save, "config_rarfile_location")
if "config_rarfile_location" in to_save: if "config_rarfile_location" in to_save:
@ -1859,11 +1873,11 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
content.sidebar_view |= constants.DETAIL_RANDOM content.sidebar_view |= constants.DETAIL_RANDOM
content.role = constants.selected_roles(to_save) content.role = constants.selected_roles(to_save)
content.password = generate_password_hash(to_save["password"])
try: try:
if not to_save["name"] or not to_save["email"] or not to_save["password"]: if not to_save["name"] or not to_save["email"] or not to_save["password"]:
log.info("Missing entries on new user") log.info("Missing entries on new user")
raise Exception(_("Oops! Please complete all fields.")) raise Exception(_("Oops! Please complete all fields."))
content.password = generate_password_hash(helper.valid_password(to_save.get("password", "")))
content.email = check_email(to_save["email"]) content.email = check_email(to_save["email"])
# Query username, if not existing, change # Query username, if not existing, change
content.name = check_username(to_save["name"]) content.name = check_username(to_save["name"])
@ -1947,14 +1961,6 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
log.warning("No admin user remaining, can't remove admin role from {}".format(content.name)) log.warning("No admin user remaining, can't remove admin role from {}".format(content.name))
flash(_("No admin user remaining, can't remove admin role"), category="error") flash(_("No admin user remaining, can't remove admin role"), category="error")
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
if to_save.get("password"):
content.password = generate_password_hash(to_save["password"])
anonymous = content.is_anonymous
content.role = constants.selected_roles(to_save)
if anonymous:
content.role |= constants.ROLE_ANONYMOUS
else:
content.role &= ~constants.ROLE_ANONYMOUS
val = [int(k[5:]) for k in to_save if k.startswith('show_')] val = [int(k[5:]) for k in to_save if k.startswith('show_')]
sidebar, __ = get_sidebar_config() sidebar, __ = get_sidebar_config()
@ -1982,6 +1988,15 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
if to_save.get("locale"): if to_save.get("locale"):
content.locale = to_save["locale"] content.locale = to_save["locale"]
try: try:
anonymous = content.is_anonymous
content.role = constants.selected_roles(to_save)
if anonymous:
content.role |= constants.ROLE_ANONYMOUS
else:
content.role &= ~constants.ROLE_ANONYMOUS
if to_save.get("password", ""):
content.password = generate_password_hash(helper.valid_password(to_save.get["password"]))
new_email = valid_email(to_save.get("email", content.email)) new_email = valid_email(to_save.get("email", content.email))
if not new_email: if not new_email:
raise Exception(_("Email can't be empty and has to be a valid Email")) raise Exception(_("Email can't be empty and has to be a valid Email"))

View File

@ -23,6 +23,10 @@ import json
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from sqlalchemy.sql.expression import text from sqlalchemy.sql.expression import text
from sqlalchemy import exists
from cryptography.fernet import Fernet
import cryptography.exceptions
from base64 import urlsafe_b64decode
try: try:
# Compatibility with sqlalchemy 2.0 # Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
@ -56,7 +60,8 @@ class _Settings(_Base):
mail_port = Column(Integer, default=25) mail_port = Column(Integer, default=25)
mail_use_ssl = Column(SmallInteger, default=0) mail_use_ssl = Column(SmallInteger, default=0)
mail_login = Column(String, default='mail@example.com') mail_login = Column(String, default='mail@example.com')
mail_password = Column(String, default='mypassword') mail_password_e = Column(String)
mail_password = Column(String)
mail_from = Column(String, default='automailer <mail@example.com>') mail_from = Column(String, default='automailer <mail@example.com>')
mail_size = Column(Integer, default=25*1024*1024) mail_size = Column(Integer, default=25*1024*1024)
mail_server_type = Column(SmallInteger, default=0) mail_server_type = Column(SmallInteger, default=0)
@ -75,7 +80,6 @@ class _Settings(_Base):
config_authors_max = Column(Integer, default=0) config_authors_max = Column(Integer, default=0)
config_read_column = Column(Integer, default=0) config_read_column = Column(Integer, default=0)
config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+') config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
# config_mature_content_tags = Column(String, default='')
config_theme = Column(Integer, default=0) config_theme = Column(Integer, default=0)
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL) config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
@ -107,6 +111,7 @@ class _Settings(_Base):
config_use_goodreads = Column(Boolean, default=False) config_use_goodreads = Column(Boolean, default=False)
config_goodreads_api_key = Column(String) config_goodreads_api_key = Column(String)
config_goodreads_api_secret_e = Column(String)
config_goodreads_api_secret = Column(String) config_goodreads_api_secret = Column(String)
config_register_email = Column(Boolean, default=False) config_register_email = Column(Boolean, default=False)
config_login_type = Column(Integer, default=0) config_login_type = Column(Integer, default=0)
@ -117,7 +122,8 @@ class _Settings(_Base):
config_ldap_port = Column(SmallInteger, default=389) config_ldap_port = Column(SmallInteger, default=389)
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE) config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org') config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
config_ldap_serv_password = Column(String, default="") config_ldap_serv_password_e = Column(String)
config_ldap_serv_password = Column(String)
config_ldap_encryption = Column(SmallInteger, default=0) config_ldap_encryption = Column(SmallInteger, default=0)
config_ldap_cacert_path = Column(String, default="") config_ldap_cacert_path = Column(String, default="")
config_ldap_cert_path = Column(String, default="") config_ldap_cert_path = Column(String, default="")
@ -148,23 +154,33 @@ class _Settings(_Base):
schedule_generate_series_covers = Column(Boolean, default=False) schedule_generate_series_covers = Column(Boolean, default=False)
schedule_reconnect = Column(Boolean, default=False) schedule_reconnect = Column(Boolean, default=False)
config_password_policy = Column(Boolean, default=True)
config_password_min_length = Column(Integer, default=8)
config_password_number = Column(Boolean, default=True)
config_password_lower = Column(Boolean, default=True)
config_password_upper = Column(Boolean, default=True)
config_password_special = Column(Boolean, default=True)
config_session = Column(Integer, default=1)
config_ratelimiter = Column(Boolean, default=True)
def __repr__(self): def __repr__(self):
return self.__class__.__name__ return self.__class__.__name__
# Class holds all application specific settings in calibre-web # Class holds all application specific settings in calibre-web
class _ConfigSQL(object): class ConfigSQL(object):
# pylint: disable=no-member # pylint: disable=no-member
def __init__(self): def __init__(self):
pass self.__dict__["dirty"] = list()
def init_config(self, session, cli): def init_config(self, session, secret_key, cli):
self._session = session self._session = session
self._settings = None self._settings = None
self.db_configured = None self.db_configured = None
self.config_calibre_dir = None self.config_calibre_dir = None
self.load() self._fernet = Fernet(secret_key)
self.cli = cli self.cli = cli
self.load()
change = False change = False
if self.config_converterpath == None: # pylint: disable=access-member-before-definition if self.config_converterpath == None: # pylint: disable=access-member-before-definition
@ -293,10 +309,10 @@ class _ConfigSQL(object):
setattr(self, field, new_value) setattr(self, field, new_value)
return True return True
def toDict(self): def to_dict(self):
storage = {} storage = {}
for k, v in self.__dict__.items(): for k, v in self.__dict__.items():
if k[0] != '_' and not k.endswith("password") and not k.endswith("secret") and not k == "cli": if k[0] != '_' and not k.endswith("_e") and not k == "cli":
storage[k] = v storage[k] = v
return storage return storage
@ -310,6 +326,12 @@ class _ConfigSQL(object):
column = s.__class__.__dict__.get(k) column = s.__class__.__dict__.get(k)
if column.default is not None: if column.default is not None:
v = column.default.arg v = column.default.arg
if k.endswith("_e") and v is not None:
try:
setattr(self, k, self._fernet.decrypt(v).decode())
except cryptography.fernet.InvalidToken:
setattr(self, k, "")
else:
setattr(self, k, v) setattr(self, k, v)
have_metadata_db = bool(self.config_calibre_dir) have_metadata_db = bool(self.config_calibre_dir)
@ -332,16 +354,20 @@ class _ConfigSQL(object):
except OperationalError as e: except OperationalError as e:
log.error('Database error: %s', e) log.error('Database error: %s', e)
self._session.rollback() self._session.rollback()
self.__dict__["dirty"] = list()
def save(self): def save(self):
"""Apply all configuration values to the underlying storage.""" """Apply all configuration values to the underlying storage."""
s = self._read_from_storage() # type: _Settings s = self._read_from_storage() # type: _Settings
for k, v in self.__dict__.items(): for k in self.dirty:
if k[0] == '_': if k[0] == '_':
continue continue
if hasattr(s, k): if hasattr(s, k):
setattr(s, k, v) if k.endswith("_e"):
setattr(s, k, self._fernet.encrypt(self.__dict__[k].encode()))
else:
setattr(s, k, self.__dict__[k])
log.debug("_ConfigSQL updating storage") log.debug("_ConfigSQL updating storage")
self._session.merge(s) self._session.merge(s)
@ -357,7 +383,6 @@ class _ConfigSQL(object):
log.error(error) log.error(error)
log.warning("invalidating configuration") log.warning("invalidating configuration")
self.db_configured = False self.db_configured = False
# self.config_calibre_dir = None
self.save() self.save()
def store_calibre_uuid(self, calibre_db, Library_table): def store_calibre_uuid(self, calibre_db, Library_table):
@ -369,8 +394,40 @@ class _ConfigSQL(object):
except AttributeError: except AttributeError:
pass pass
def __setattr__(self, attr_name, attr_value):
super().__setattr__(attr_name, attr_value)
self.__dict__["dirty"].append(attr_name)
def _migrate_table(session, orm_class):
def _encrypt_fields(session, secret_key):
try:
session.query(exists().where(_Settings.mail_password_e)).scalar()
except OperationalError:
with session.bind.connect() as conn:
conn.execute("ALTER TABLE settings ADD column 'mail_password_e' String")
conn.execute("ALTER TABLE settings ADD column 'config_goodreads_api_secret_e' String")
conn.execute("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String")
session.commit()
crypter = Fernet(secret_key)
settings = session.query(_Settings.mail_password, _Settings.config_goodreads_api_secret,
_Settings.config_ldap_serv_password).first()
if settings.mail_password:
session.query(_Settings).update(
{_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())})
if settings.config_goodreads_api_secret:
session.query(_Settings).update(
{_Settings.config_goodreads_api_secret_e:
crypter.encrypt(settings.config_goodreads_api_secret.encode())})
if settings.config_ldap_serv_password:
session.query(_Settings).update(
{_Settings.config_ldap_serv_password_e:
crypter.encrypt(settings.config_ldap_serv_password.encode())})
session.commit()
def _migrate_table(session, orm_class, secret_key=None):
if secret_key:
_encrypt_fields(session, secret_key)
changed = False changed = False
for column_name, column in orm_class.__dict__.items(): for column_name, column in orm_class.__dict__.items():
@ -446,22 +503,18 @@ def autodetect_kepubify_binary():
return "" return ""
def _migrate_database(session): def _migrate_database(session, secret_key):
# make sure the table is created, if it does not exist # make sure the table is created, if it does not exist
_Base.metadata.create_all(session.bind) _Base.metadata.create_all(session.bind)
_migrate_table(session, _Settings) _migrate_table(session, _Settings, secret_key)
_migrate_table(session, _Flask_Settings) _migrate_table(session, _Flask_Settings)
def load_configuration(conf, session, cli): def load_configuration(session, secret_key):
_migrate_database(session) _migrate_database(session, secret_key)
if not session.query(_Settings).count(): if not session.query(_Settings).count():
session.add(_Settings()) session.add(_Settings())
session.commit() session.commit()
# conf = _ConfigSQL()
conf.init_config(session, cli)
# return conf
def get_flask_session_key(_session): def get_flask_session_key(_session):
@ -471,3 +524,25 @@ def get_flask_session_key(_session):
_session.add(flask_settings) _session.add(flask_settings)
_session.commit() _session.commit()
return flask_settings.flask_session_key return flask_settings.flask_session_key
def get_encryption_key(key_path):
key_file = os.path.join(key_path, ".key")
generate = True
error = ""
if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
with open(key_file, "rb") as f:
key = f.read()
try:
urlsafe_b64decode(key)
generate = False
except ValueError:
pass
if generate:
key = Fernet.generate_key()
try:
with open(key_file, "wb") as f:
f.write(key)
except PermissionError as e:
error = e
return key, error

View File

@ -65,7 +65,7 @@ def send_debug():
file_list.remove(element) file_list.remove(element)
memory_zip = BytesIO() memory_zip = BytesIO()
with zipfile.ZipFile(memory_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(memory_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr('settings.txt', json.dumps(config.toDict(), sort_keys=True, indent=2)) zf.writestr('settings.txt', json.dumps(config.to_dict(), sort_keys=True, indent=2))
zf.writestr('libs.txt', json.dumps(collect_stats(), sort_keys=True, indent=2, cls=lazyEncoder)) zf.writestr('libs.txt', json.dumps(collect_stats(), sort_keys=True, indent=2, cls=lazyEncoder))
for fp in file_list: for fp in file_list:
zf.write(fp, os.path.basename(fp)) zf.write(fp, os.path.basename(fp))

View File

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import os import os
import random
import io import io
import mimetypes import mimetypes
import re import re
@ -612,7 +613,7 @@ def reset_password(user_id):
if not config.get_mail_server_configured(): if not config.get_mail_server_configured():
return 2, None return 2, None
try: try:
password = generate_random_password() password = generate_random_password(config.config_password_min_length)
existing_user.password = generate_password_hash(password) existing_user.password = generate_password_hash(password)
ub.session.commit() ub.session.commit()
send_registration_mail(existing_user.email, existing_user.name, password, True) send_registration_mail(existing_user.email, existing_user.name, password, True)
@ -621,11 +622,35 @@ def reset_password(user_id):
ub.session.rollback() ub.session.rollback()
return 0, None return 0, None
def generate_random_password(min_length):
min_length = max(8, min_length) - 4
random_source = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
# select 1 lowercase
s = "abcdefghijklmnopqrstuvwxyz"
password = [s[c % len(s)] for c in os.urandom(1)]
# select 1 uppercase
s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
password.extend([s[c % len(s)] for c in os.urandom(1)])
# select 1 digit
s = "01234567890"
password.extend([s[c % len(s)] for c in os.urandom(1)])
# select 1 special symbol
s = "!@#$%&*()?"
password.extend([s[c % len(s)] for c in os.urandom(1)])
def generate_random_password(): # generate other characters
password.extend([random_source[c % len(random_source)] for c in os.urandom(min_length)])
# password_list = list(password)
# shuffle all characters
random.SystemRandom().shuffle(password)
return ''.join(password)
'''def generate_random_password(min_length):
s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?" s = "abcdefghijklmnopqrstuvwxyz01234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%&*()?"
passlen = 8 passlen = min_length
return "".join(s[c % len(s)] for c in os.urandom(passlen)) return "".join(s[c % len(s)] for c in os.urandom(passlen))'''
def uniq(inpt): def uniq(inpt):
@ -664,6 +689,23 @@ def valid_email(email):
raise Exception(_("Invalid Email address format")) raise Exception(_("Invalid Email address format"))
return email return email
def valid_password(check_password):
if config.config_password_policy:
verify = ""
if config.config_password_min_length > 0:
verify += "^(?=.{" + str(config.config_password_min_length) + ",}$)"
if config.config_password_number:
verify += "(?=.*?\d)"
if config.config_password_lower:
verify += "(?=.*?[a-z])"
if config.config_password_upper:
verify += "(?=.*?[A-Z])"
if config.config_password_special:
verify += "(?=.*?[^A-Za-z\s0-9])"
match = re.match(verify, check_password)
if not match:
raise Exception(_("Password doesn't comply with password validation rules"))
return check_password
# ################################# External interface ################################# # ################################# External interface #################################

View File

@ -64,11 +64,12 @@ from datetime import datetime
from os import urandom from os import urandom
from functools import wraps from functools import wraps
from flask import g, Blueprint, url_for, abort, request from flask import g, Blueprint, abort, request
from flask_login import login_user, current_user, login_required from flask_login import login_user, current_user, login_required
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_limiter import RateLimitExceeded
from . import logger, config, calibre_db, db, helper, ub, lm from . import logger, config, calibre_db, db, helper, ub, lm, limiter
from .render_template import render_title_template from .render_template import render_title_template
log = logger.create() log = logger.create()
@ -151,6 +152,10 @@ def requires_kobo_auth(f):
def inner(*args, **kwargs): def inner(*args, **kwargs):
auth_token = get_auth_token() auth_token = get_auth_token()
if auth_token is not None: if auth_token is not None:
try:
limiter.check()
except RateLimitExceeded:
return abort(429)
user = ( user = (
ub.session.query(ub.User) ub.session.query(ub.User)
.join(ub.RemoteAuthToken) .join(ub.RemoteAuthToken)
@ -159,6 +164,7 @@ def requires_kobo_auth(f):
) )
if user is not None: if user is not None:
login_user(user) login_user(user)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
return f(*args, **kwargs) return f(*args, **kwargs)
log.debug("Received Kobo request without a recognizable auth token.") log.debug("Received Kobo request without a recognizable auth token.")
return abort(401) return abort(401)

View File

@ -18,9 +18,14 @@
import sys import sys
from . import create_app from . import create_app, limiter
from .jinjia import jinjia from .jinjia import jinjia
from .remotelogin import remotelogin from .remotelogin import remotelogin
from flask import request
def request_username():
return request.authorization.username
def main(): def main():
app = create_app() app = create_app()
@ -39,6 +44,7 @@ def main():
try: try:
from .kobo import kobo, get_kobo_activated from .kobo import kobo, get_kobo_activated
from .kobo_auth import kobo_auth from .kobo_auth import kobo_auth
from flask_limiter.util import get_remote_address
kobo_available = get_kobo_activated() kobo_available = get_kobo_activated()
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator) except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
kobo_available = False kobo_available = False
@ -56,6 +62,7 @@ def main():
app.register_blueprint(tasks) app.register_blueprint(tasks)
app.register_blueprint(web) app.register_blueprint(web)
app.register_blueprint(opds) app.register_blueprint(opds)
limiter.limit("3/minute",key_func=request_username)(opds)
app.register_blueprint(jinjia) app.register_blueprint(jinjia)
app.register_blueprint(about) app.register_blueprint(about)
app.register_blueprint(shelf) app.register_blueprint(shelf)
@ -67,6 +74,7 @@ def main():
if kobo_available: if kobo_available:
app.register_blueprint(kobo) app.register_blueprint(kobo)
app.register_blueprint(kobo_auth) app.register_blueprint(kobo_auth)
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
if oauth_available: if oauth_available:
app.register_blueprint(oauth) app.register_blueprint(oauth)
success = web_server.start() success = web_server.start()

View File

@ -27,8 +27,7 @@ from .tasks.metadata_backup import TaskBackupMetadata
def get_scheduled_tasks(reconnect=True): def get_scheduled_tasks(reconnect=True):
tasks = list() tasks = list()
# config.schedule_reconnect or # Reconnect Calibre database (metadata.db) based on config.schedule_reconnect
# Reconnect Calibre database (metadata.db)
if reconnect: if reconnect:
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False]) tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])

View File

@ -22,7 +22,6 @@ import errno
import signal import signal
import socket import socket
import subprocess # nosec import subprocess # nosec
from .services.background_scheduler import BackgroundScheduler
try: try:
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
@ -270,6 +269,7 @@ class WebServer(object):
@staticmethod @staticmethod
def shutdown_scheduler(): def shutdown_scheduler():
from .services.background_scheduler import BackgroundScheduler
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
if scheduler: if scheduler:
scheduler.scheduler.shutdown() scheduler.scheduler.shutdown()

View File

@ -44,15 +44,15 @@ def init_app(app, config):
app.config['LDAP_SCHEMA'] = 'ldap' app.config['LDAP_SCHEMA'] = 'ldap'
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS: if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE: if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
if config.config_ldap_serv_password is None: if config.config_ldap_serv_password_e is None:
config.config_ldap_serv_password = '' config.config_ldap_serv_password_e = ''
app.config['LDAP_PASSWORD'] = base64.b64decode(config.config_ldap_serv_password) app.config['LDAP_PASSWORD'] = config.config_ldap_serv_password_e
else: else:
app.config['LDAP_PASSWORD'] = base64.b64decode("") app.config['LDAP_PASSWORD'] = ""
app.config['LDAP_USERNAME'] = config.config_ldap_serv_username app.config['LDAP_USERNAME'] = config.config_ldap_serv_username
else: else:
app.config['LDAP_USERNAME'] = "" app.config['LDAP_USERNAME'] = ""
app.config['LDAP_PASSWORD'] = base64.b64decode("") app.config['LDAP_PASSWORD'] = ""
if bool(config.config_ldap_cert_path): if bool(config.config_ldap_cert_path):
app.config['LDAP_CUSTOM_OPTIONS'].update({ app.config['LDAP_CUSTOM_OPTIONS'].update({
pyLDAP.OPT_X_TLS_REQUIRE_CERT: pyLDAP.OPT_X_TLS_DEMAND, pyLDAP.OPT_X_TLS_REQUIRE_CERT: pyLDAP.OPT_X_TLS_DEMAND,

View File

@ -434,3 +434,7 @@ div.log {
#detailcover:-moz-full-screen { cursor:zoom-out; border: 0; } #detailcover:-moz-full-screen { cursor:zoom-out; border: 0; }
#detailcover:-ms-fullscreen { cursor:zoom-out; border: 0; } #detailcover:-ms-fullscreen { cursor:zoom-out; border: 0; }
#detailcover:fullscreen { cursor:zoom-out; border: 0; } #detailcover:fullscreen { cursor:zoom-out; border: 0; }
.error-list {
margin-top: 5px;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "كلمة المرور قصيرة جداً",
"wordMaxLength": "كلمة المرور طويلة جدا",
"wordInvalidChar": "تحتوي كلمة المرور على رموز غير صالحة",
"wordNotEmail": "لا تستخدم بريدك الإلكتروني ككلمة مرور",
"wordSimilarToUsername": "لا يمكن ان تحتوي كلمة المرور على إسم المستخدم",
"wordTwoCharacterClasses": "إستخدم فئات أحرف مختلفة",
"wordRepetitions": "تكرارات كثيرة",
"wordSequences": "تحتوي كلمة المرور على أنماط متتابعة",
"errorList": "الأخطاء:",
"veryWeak": "ضعيفة جداً",
"weak": "ضعيفة",
"normal": "عادية",
"medium": "متوسطة",
"strong": "قوية",
"veryStrong": "قوية جداً"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Vaše heslo je příliš krátké",
"wordMaxLength": "Vaše heslo je příliš dlouhé",
"wordInvalidChar": "Vaše heslo obsahuje neplatný znak",
"wordNotEmail": "Nepoužívejte Váš email jako Vaše heslo",
"wordSimilarToUsername": "Vaše heslo nesmí obsahovat přihlašovací jméno",
"wordTwoCharacterClasses": "Použijte různé druhy znaků",
"wordRepetitions": "Příliš mnoho opakování",
"wordSequences": "Vaše heslo obsahuje postupnost",
"errorList": "Chyby:",
"veryWeak": "Velmi slabé",
"weak": "Slabé",
"normal": "Normální",
"medium": "Středně silné",
"strong": "Silné",
"veryStrong": "Velmi silné"
}

View File

@ -0,0 +1,21 @@
{
"wordMinLength": "Das Passwort ist zu kurz",
"wordMaxLength": "Das Passwort ist zu lang",
"wordInvalidChar": "Das Passwort enthält ein ungültiges Zeichen",
"wordNotEmail": "Das Passwort darf die E-Mail Adresse nicht enthalten",
"wordSimilarToUsername": "Das Passwort darf den Benutzernamen nicht enthalten",
"wordTwoCharacterClasses": "Bitte Buchstaben und Ziffern verwenden",
"wordRepetitions": "Zu viele Wiederholungen",
"wordSequences": "Das Passwort enthält Buchstabensequenzen",
"wordLowercase": "Bitte mindestens einen Kleinbuchstaben verwenden",
"wordUppercase": "Bitte mindestens einen Großbuchstaben verwenden",
"wordOneNumber": "Bitte mindestens eine Ziffern verwenden",
"wordOneSpecialChar": "Bitte mindestens ein Sonderzeichen verwenden",
"errorList": "Fehler:",
"veryWeak": "Sehr schwach",
"weak": "Schwach",
"normal": "Normal",
"medium": "Mittel",
"strong": "Stark",
"veryStrong": "Sehr stark"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Ο κωδικός πρόσβασης δεν έχει τον ελάχιστο αριθμό χαρακτήρων",
"wordMaxLength": "Ο κωδικός πρόσβασής σας είναι πολύ μεγάλος",
"wordInvalidChar": "Ο κωδικός πρόσβασής σας περιέχει έναν μη έγκυρο χαρακτήρα",
"wordNotEmail": "Μη χρησιμοποιείτε το email ως κωδικό",
"wordSimilarToUsername": "Ο κωδικός πρόσβασης δεν πρέπει να περιέχει το username",
"wordTwoCharacterClasses": "Χρησιμοποιήστε διαφορετικές κλάσεις χαρακτήρων",
"wordRepetitions": "Πολλές επαναλήψεις",
"wordSequences": "Ο κωδικός πρόσβασης περιέχει επαναλήψεις",
"errorList": "Σφάλματα:",
"veryWeak": "Πολύ Αδύνατος",
"weak": "Αδύνατος",
"normal": "Κανονικός",
"medium": "Μέτριος",
"strong": "Δυνατός",
"veryStrong": "Πολύ Δυνατός"
}

View File

@ -0,0 +1,21 @@
{
"wordMinLength": "Your password is too short",
"wordMaxLength": "Your password is too long",
"wordInvalidChar": "Your password contains an invalid character",
"wordNotEmail": "Do not use your email as your password",
"wordSimilarToUsername": "Your password cannot contain your username",
"wordTwoCharacterClasses": "Use different character classes",
"wordRepetitions": "Too many repetitions",
"wordSequences": "Your password contains sequences",
"wordLowercase": "Use at least one lowercase character",
"wordUppercase": "Use at least one uppercase character",
"wordOneNumber": "Use at least one number",
"wordOneSpecialChar": "Use at least one special character",
"errorList": "Errors:",
"veryWeak": "Very Weak",
"weak": "Weak",
"normal": "Normal",
"medium": "Medium",
"strong": "Strong",
"veryStrong": "Very Strong"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Via pasvorto estas tro mallonga",
"wordMaxLength": "Via pasvorto estas tro longa",
"wordInvalidChar": "Via pasvorto enhavas nevalidan karaktero",
"wordNotEmail": "Ne uzu vian retpoŝtadreson kiel la pasvorton",
"wordSimilarToUsername": "Via pasvorto enhavas vian uzanto-nomon",
"wordTwoCharacterClasses": "Uzu signojn de diversaj tipoj (ekz., literoj kaj ciferoj)",
"wordRepetitions": "Tro multaj ripetiĝantaj signoj",
"wordSequences": "Via pasvorto enhavas simplan sinsekvon de signoj",
"errorList": "Eraroj:",
"veryWeak": "Trosimpla",
"weak": "Malforta",
"normal": "Mezforta",
"medium": "Akceptebla",
"strong": "Forta",
"veryStrong": "Elstare Forta"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Tu contraseña es demasiado corta",
"wordMaxLength": "Tu contraseña es muy larga",
"wordInvalidChar": "Tu contraseña contiene un carácter no válido",
"wordNotEmail": "No uses tu email como tu contraseña",
"wordSimilarToUsername": "Tu contraseña no puede contener tu nombre de usuario",
"wordTwoCharacterClasses": "Mezcla diferentes clases de caracteres",
"wordRepetitions": "Demasiadas repeticiones",
"wordSequences": "Tu contraseña contiene secuencias",
"errorList": "Errores:",
"veryWeak": "Muy Débil",
"weak": "Débil",
"normal": "Normal",
"medium": "Media",
"strong": "Fuerte",
"veryStrong": "Muy Fuerte"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Votre mot de passe est trop court",
"wordMaxLength": "Votre mot de passe est trop long",
"wordInvalidChar": "Votre mot de passe contient un caractère invalide",
"wordNotEmail": "Ne pas utiliser votre adresse e-mail comme mot de passe",
"wordSimilarToUsername": "Votre mot de passe ne peut pas contenir votre nom d'utilisateur",
"wordTwoCharacterClasses": "Utilisez différents type de caractères",
"wordRepetitions": "Trop de répétitions",
"wordSequences": "Votre mot de passe contient des séquences",
"errorList": "Erreurs:",
"veryWeak": "Très Faible",
"weak": "Faible",
"normal": "Normal",
"medium": "Moyen",
"strong": "Fort",
"veryStrong": "Très Fort"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "La tua password è troppo corta",
"wordMaxLength": "La tua password è troppo lunga",
"wordInvalidChar": "La tua password contiene un carattere non valido",
"wordNotEmail": "Non usare la tua e-mail come password",
"wordSimilarToUsername": "La tua password non può contenere il tuo nome",
"wordTwoCharacterClasses": "Usa classi di caratteri diversi",
"wordRepetitions": "Troppe ripetizioni",
"wordSequences": "La tua password contiene sequenze",
"errorList": "Errori:",
"veryWeak": "Molto debole",
"weak": "Debole",
"normal": "Normale",
"medium": "Media",
"strong": "Forte",
"veryStrong": "Molto forte"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Ditt passord er for kort",
"wordMaxLength": "Ditt passord er for langt",
"wordInvalidChar": "Ditt passord inneholder et ugyldig tegn",
"wordNotEmail": "Ikke bruk din epost som ditt passord",
"wordSimilarToUsername": "Ditt passord er for likt ditt brukernavn",
"wordTwoCharacterClasses": "Bruk en kombinasjon av bokstaver, tall og andre tegn",
"wordRepetitions": "For mange repitisjoner",
"wordSequences": "Ditt passord inneholder repeterende tegn",
"errorList": "Feil:",
"veryWeak": "Veldig Svakt",
"weak": "Svakt",
"normal": "Normal",
"medium": "Medium",
"strong": "Sterkt",
"veryStrong": "Veldig Sterkt"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Hasło jest zbyt krótkie",
"wordMaxLength": "Hasło jest za długie",
"wordInvalidChar": "Hasło zawiera nieprawidłowy znak",
"wordNotEmail": "Hasło nie może być Twoim emailem",
"wordSimilarToUsername": "Hasło nie może zawierać nazwy użytkownika",
"wordTwoCharacterClasses": "Użyj innych klas znaków",
"wordRepetitions": "Zbyt wiele powtórzeń",
"wordSequences": "Hasło zawiera sekwencje",
"errorList": "Błędy:",
"veryWeak": "Bardzo słabe",
"weak": "Słabe",
"normal": "Normalne",
"medium": "Średnie",
"strong": "Silne",
"veryStrong": "Bardzo silne"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Sua senha é muito curta",
"wordMaxLength": "Sua senha é muito longa",
"wordInvalidChar": "Sua senha contém um caractere inválido",
"wordNotEmail": "Não use seu e-mail como senha",
"wordSimilarToUsername": "Sua senha não pode conter o seu nome de usuário",
"wordTwoCharacterClasses": "Use diferentes classes de caracteres",
"wordRepetitions": "Muitas repetições",
"wordSequences": "Sua senha contém sequências",
"errorList": "Erros:",
"veryWeak": "Muito Fraca",
"weak": "Fraca",
"normal": "Normal",
"medium": "Média",
"strong": "Forte",
"veryStrong": "Muito Forte"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Слишком короткий пароль",
"wordMaxLength": "Ваш пароль слишком длинный",
"wordInvalidChar": "Ваш пароль содержит недопустимый символ",
"wordNotEmail": "Не используйте e-mail в качестве пароля",
"wordSimilarToUsername": "Пароль не должен содержать логин",
"wordTwoCharacterClasses": "Используйте разные классы символов",
"wordRepetitions": "Слишком много повторений",
"wordSequences": "Пароль содержит последовательности",
"errorList": "Ошибки:",
"veryWeak": "Очень слабый",
"weak": "Слабый",
"normal": "Нормальный",
"medium": "Средний",
"strong": "Сильный",
"veryStrong": "Очень сильный"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Vaše heslo je príliž krátke",
"wordMaxLength": "Vaše heslo je príliš dlhé",
"wordInvalidChar": "Vaše heslo obsahuje neplatný znak",
"wordNotEmail": "Nepoužívajte Váš email ako Vaše heslo",
"wordSimilarToUsername": "Vaše heslo nesmie obsahovať prihlasovacie meno",
"wordTwoCharacterClasses": "Použite rôzne druhy znakov",
"wordRepetitions": "Príliš veľa opakovaní",
"wordSequences": "Vaše heslo obsahuje postupnosť",
"errorList": "Chyby:",
"veryWeak": "Veľmi slabé",
"weak": "Slabé",
"normal": "Normálne",
"medium": "Stredne silné",
"strong": "Silné",
"veryStrong": "Veľmi silné"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "รหัสผ่านของคุณสั้นเกินไป",
"wordMaxLength": "รหัสผ่านของคุณยาวเกินไป",
"wordInvalidChar": "รหัสผ่านของคุณมีอักษรที่ไม่ถูกต้อง",
"wordNotEmail": "คุณไม่สามารถใช้รหัสผ่านเหมือนกับอีเมล์ของคุณได้",
"wordSimilarToUsername": "รหัสผ่านไม่ควรประกอบด้วยคำที่เป็น username",
"wordTwoCharacterClasses": "ลองเป็นกลุ่มคำใหม่",
"wordRepetitions": "มีอักษรซ้ำเยอะเกินไป",
"wordSequences": "รหัสผ่านของคุณเดาง่ายเกินไป",
"errorList": "Errors:",
"veryWeak": "เดาง่ายมาก",
"weak": "เดาง่าย",
"normal": "พอใช้",
"medium": "กำลังดี",
"strong": "ค่อนข้างดี",
"veryStrong": "ดีมาก"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "Girdiğiniz şifre çok Kısa",
"wordMaxLength": "Parolanız çok uzun",
"wordInvalidChar": "Şifreniz geçersiz bir karakter içeriyor",
"wordNotEmail": "E-mail adresinizi şifreniz içerisinde kullanmayınız",
"wordSimilarToUsername": "Kullanıcı Adınızı şifreniz içerisinde kullanmayınız",
"wordTwoCharacterClasses": "Başka karakter sınıfı kullanınız",
"wordRepetitions": "Çok fazla tekrar var",
"wordSequences": "Şifreniz Dizi içermektedir",
"errorList": "Hatalar:",
"veryWeak": "Çok Zayıf",
"weak": "Zayıf",
"normal": "Normal",
"medium": "Orta",
"strong": "Güçlü",
"veryStrong": "Çok Güçlü"
}

View File

@ -0,0 +1,17 @@
{
"wordMinLength": "您的密碼太短",
"wordMaxLength": "您的密碼太長",
"wordInvalidChar": "您的密碼包含無效字符",
"wordNotEmail": "不要使用電子郵件作為密碼",
"wordSimilarToUsername": "您的密碼不能包含您的用戶名",
"wordTwoCharacterClasses": "使用不同的字元類型 例如: 大小寫混合",
"wordRepetitions": "太多的重複。例如:1111",
"wordSequences": "你的密碼包含連續英/數字 例如:123 or abc",
"errorList": "錯誤:",
"veryWeak": "非常弱",
"weak": "弱",
"normal": "普通",
"medium": "中等",
"strong": "強",
"veryStrong": "非常強"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

65
cps/static/js/password.js Normal file
View File

@ -0,0 +1,65 @@
/* This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
* Copyright (C) 2022 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/>.
*/
$(document).ready(function() {
i18next.use(i18nextHttpBackend).init({
lng: $('#password').data("lang"),
debug: false,
fallbackLng: 'en',
backend: {
loadPath: getPath() + "/static/js/libs/pwstrength/locales/{{lng}}.json",
},
}, function () {
if ($('#password').data("verify")) {
// Initialized and ready to go
var options = {};
options.common = {
minChar: $('#password').data("min"),
maxChar: -1
}
options.ui = {
bootstrap3: true,
showProgressBar: false,
showErrors: true,
showVerdicts: false,
}
options.rules= {
specialCharClass: "(?=.*?[^A-Za-z\\s0-9])",
activated: {
wordNotEmail: false,
wordMinLength: $('#password').data("min"),
// wordMaxLength: false,
// wordInvalidChar: true,
wordSimilarToUsername: false,
wordSequences: false,
wordTwoCharacterClasses: false,
wordRepetitions: false,
wordLowercase: $('#password').data("lower") === "True" ? true : false,
wordUppercase: $('#password').data("upper") === "True" ? true : false,
wordOneNumber: $('#password').data("number") === "True" ? true : false,
wordThreeNumbers: false,
wordOneSpecialChar: $('#password').data("special") === "True" ? true : false,
// wordTwoSpecialChar: true,
wordUpperLowerCombo: false,
wordLetterNumberCombo: false,
wordLetterNumberCharCombo: false
}
}
$('#password').pwstrength(options);
}
});
});

View File

@ -634,7 +634,7 @@ function UserActions (value, row) {
/* Function for cancelling tasks */ /* Function for cancelling tasks */
function TaskActions (value, row) { function TaskActions (value, row) {
var cancellableStats = [0, 1, 2]; var cancellableStats = [0, 2];
if (row.task_id && row.is_cancellable && cancellableStats.includes(row.stat)) { if (row.task_id && row.is_cancellable && cancellableStats.includes(row.stat)) {
return [ return [
"<div class=\"danger task-cancel\" data-toggle=\"modal\" data-target=\"#cancelTaskModal\" data-task-id=\"" + row.task_id + "\" title=\"Cancel\">", "<div class=\"danger task-cancel\" data-toggle=\"modal\" data-target=\"#cancelTaskModal\" data-task-id=\"" + row.task_id + "\" title=\"Cancel\">",

View File

@ -202,8 +202,8 @@ class TaskEmail(CalibreTask):
self.asyncSMTP.set_debuglevel(1) self.asyncSMTP.set_debuglevel(1)
if use_ssl == 1: if use_ssl == 1:
self.asyncSMTP.starttls() self.asyncSMTP.starttls()
if self.settings["mail_password"]: if self.settings["mail_password_e"]:
self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password"])) self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password_e"]))
# Convert message to something to send # Convert message to something to send
fp = StringIO() fp = StringIO()

View File

@ -22,8 +22,8 @@ from urllib.request import urlopen
from lxml import etree from lxml import etree
from html import escape from html import escape
from cps import config, db, fs, gdriveutils, logger, ub from cps import config, db, gdriveutils, logger
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED from cps.services.worker import CalibreTask
from flask_babel import lazy_gettext as N_ from flask_babel import lazy_gettext as N_
OPF_NAMESPACE = "http://www.idpf.org/2007/opf" OPF_NAMESPACE = "http://www.idpf.org/2007/opf"
@ -74,7 +74,10 @@ class TaskBackupMetadata(CalibreTask):
def backup_metadata(self): def backup_metadata(self):
try: try:
metadata_backup = self.calibre_db.session.query(db.Metadata_Dirtied).all() metadata_backup = self.calibre_db.session.query(db.Metadata_Dirtied).all()
custom_columns = self.calibre_db.session.query(db.CustomColumns).order_by(db.CustomColumns.label).all() custom_columns = (self.calibre_db.session.query(db.CustomColumns)
.filter(db.CustomColumns.mark_for_delete == 0)
.filter(db.CustomColumns.datatype.notin_(db.cc_exceptions))
.order_by(db.CustomColumns.label).all())
count = len(metadata_backup) count = len(metadata_backup)
i = 0 i = 0
for backup in metadata_backup: for backup in metadata_backup:
@ -226,11 +229,11 @@ class TaskBackupMetadata(CalibreTask):
# doc = etree.tostring(package, xml_declaration=True, encoding='utf-8', pretty_print=True) # .replace(b"&amp;quot;", b"&quot;") # doc = etree.tostring(package, xml_declaration=True, encoding='utf-8', pretty_print=True) # .replace(b"&amp;quot;", b"&quot;")
try: try:
with open(book_metadata_filepath, 'wb') as f: with open(book_metadata_filepath, 'wb') as f:
# f.write(doc)
doc.write(f, xml_declaration=True, encoding='utf-8', pretty_print=True) doc.write(f, xml_declaration=True, encoding='utf-8', pretty_print=True)
except Exception: except Exception:
# ToDo: Folder not writeable error # ToDo: Folder not writeable error
pass pass
@property @property
def name(self): def name(self):
return "Metadata backup" return "Metadata backup"

View File

@ -61,27 +61,27 @@
<div class="col"> <div class="col">
<h2>{{_('Email Server Settings')}}</h2> <h2>{{_('Email Server Settings')}}</h2>
{% if config.get_mail_server_configured() %} {% if config.get_mail_server_configured() %}
{% if email.mail_server_type == 0 %} {% if config.mail_server_type == 0 %}
<div class="col-xs-12 col-sm-12"> <div class="col-xs-12 col-sm-12">
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('SMTP Hostname')}}</div> <div class="col-xs-6 col-sm-3">{{_('SMTP Hostname')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_server}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_server}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('SMTP Port')}}</div> <div class="col-xs-6 col-sm-3">{{_('SMTP Port')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_port}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_port}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('Encryption')}}</div> <div class="col-xs-6 col-sm-3">{{_('Encryption')}}</div>
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(email.mail_use_ssl) }}</div> <div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.mail_use_ssl) }}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('SMTP Login')}}</div> <div class="col-xs-6 col-sm-3">{{_('SMTP Login')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_login}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_login}}</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('From Email')}}</div> <div class="col-xs-6 col-sm-3">{{_('From Email')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_from}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_from}}</div>
</div> </div>
</div> </div>
{% else %} {% else %}
@ -92,7 +92,7 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6 col-sm-3">{{_('From Email')}}</div> <div class="col-xs-6 col-sm-3">{{_('From Email')}}</div>
<div class="col-xs-6 col-sm-3">{{email.mail_gmail_token['email']}}</div> <div class="col-xs-6 col-sm-3">{{config.mail_gmail_token['email']}}</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -159,8 +159,8 @@
<input type="text" class="form-control" id="config_goodreads_api_key" name="config_goodreads_api_key" value="{% if config.config_goodreads_api_key != None %}{{ config.config_goodreads_api_key }}{% endif %}" autocomplete="off"> <input type="text" class="form-control" id="config_goodreads_api_key" name="config_goodreads_api_key" value="{% if config.config_goodreads_api_key != None %}{{ config.config_goodreads_api_key }}{% endif %}" autocomplete="off">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="config_goodreads_api_secret">{{_('Goodreads API Secret')}}</label> <label for="config_goodreads_api_secret_e">{{_('Goodreads API Secret')}}</label>
<input type="text" class="form-control" id="config_goodreads_api_secret" name="config_goodreads_api_secret" value="{% if config.config_goodreads_api_secret != None %}{{ config.config_goodreads_api_secret }}{% endif %}" autocomplete="off"> <input type="password" class="form-control" id="config_goodreads_api_secret_e" name="config_goodreads_api_secret_e" value="{% if config.config_goodreads_api_secret_e != None %}{{ config.config_goodreads_api_secret_e }}{% endif %}" autocomplete="off">
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -245,8 +245,8 @@
</div> </div>
<div data-related="ldap-auth-password-2"> <div data-related="ldap-auth-password-2">
<div class="form-group"> <div class="form-group">
<label for="config_ldap_serv_password">{{_('LDAP Administrator Password')}}</label> <label for="config_ldap_serv_password_e">{{_('LDAP Administrator Password')}}</label>
<input type="password" class="form-control" id="config_ldap_serv_password" name="config_ldap_serv_password" value="" autocomplete="off"> <input type="password" class="form-control" id="config_ldap_serv_password_e" name="config_ldap_serv_password_e" value="" autocomplete="off">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -353,6 +353,57 @@
</div> </div>
</div> </div>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a class="accordion-toggle" data-toggle="collapse" href="#collapsesix">
<span class="glyphicon glyphicon-plus"></span>
{{_('Securitiy Settings')}}
</a>
</h4>
</div>
<div id="collapsesix" class="panel-collapse collapse">
<div class="panel-body">
<div class="form-group">
<input type="checkbox" id="config_ratelimiter" name="config_ratelimiter" {% if config.config_ratelimiter %}checked{% endif %}>
<label for="config_ratelimiter">{{_('Limit failed login attempts')}}</label>
</div>
<div class="form-group">
<label for="config_session">{{_('Session protection')}}</label>
<select name="config_session" id="config_session" class="form-control">
<option value="0" {% if config.config_session == 0 %}selected{% endif %}>{{_('Basic')}}</option>
<option value="1" {% if config.config_session == 1 %}selected{% endif %}>{{_('Strong')}}</option>
</select>
</div>
<div class="form-group">
<input type="checkbox" id="config_password_policy" data-control="password_settings" name="config_password_policy" {% if config.config_password_policy %}checked{% endif %}>
<label for="config_password_policy">{{_('User Password policy')}}</label>
</div>
<div data-related="password_settings">
<div class="form-group" style="margin-left:10px;">
<label for="config_password_min_length">{{_('Minimum password length')}}</label>
<input type="number" min="1" max="40" class="form-control" name="config_password_min_length" id="config_password_min_length" value="{% if config.config_password_min_length != None %}{{ config.config_password_min_length }}{% endif %}" autocomplete="off" required>
</div>
<div class="form-group" style="margin-left:10px;">
<input type="checkbox" id="config_password_number" name="config_password_number" {% if config.config_password_number %}checked{% endif %}>
<label for="config_password_number">{{_('Enforce number')}}</label>
</div>
<div class="form-group" style="margin-left:10px;">
<input type="checkbox" id="config_password_lower" name="config_password_lower" {% if config.config_password_lower %}checked{% endif %}>
<label for="config_password_lower">{{_('Enforce lowercase characters')}}</label>
</div>
<div class="form-group" style="margin-left:10px;">
<input type="checkbox" id="config_password_upper" name="config_password_upper" {% if config.config_password_upper %}checked{% endif %}>
<label for="config_password_upper">{{_('Enforce uppercase characters')}}</label>
</div>
<div class="form-group" style="margin-left:10px;">
<input type="checkbox" id="config_password_special" name="config_password_special" {% if config.config_password_special %}checked{% endif %}>
<label for="config_password_special">{{_('Enforce special characters')}}</label>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="col-sm-12"> <div class="col-sm-12">
<button type="button" name="submit" id="config_submit" class="btn btn-default">{{_('Save')}}</button> <button type="button" name="submit" id="config_submit" class="btn btn-default">{{_('Save')}}</button>

View File

@ -48,8 +48,8 @@
<input type="text" class="form-control" name="mail_login" id="mail_login" value="{{content.mail_login}}"> <input type="text" class="form-control" name="mail_login" id="mail_login" value="{{content.mail_login}}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="mail_password">{{_('SMTP Password')}}</label> <label for="mail_password_e">{{_('SMTP Password')}}</label>
<input type="password" class="form-control" name="mail_password" id="mail_password" value="{{content.mail_password}}"> <input type="password" class="form-control" name="mail_password_e" id="mail_password_e" value="{{content.mail_password_e}}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="mail_from">{{_('From Email')}}</label> <label for="mail_from">{{_('From Email')}}</label>

View File

@ -0,0 +1,10 @@
{
"input": {
"placeholder": "a placeholder"
},
"nav": {
"home": "Home",
"page1": "Page One",
"page2": "Page Two"
}
}

View File

@ -7,11 +7,11 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group"> <div class="form-group">
<label for="username">{{_('Username')}}</label> <label for="username">{{_('Username')}}</label>
<input type="text" class="form-control" id="username" name="username" autocapitalize="off" placeholder="{{_('Username')}}"> <input type="text" class="form-control" id="username" name="username" autocapitalize="off" placeholder="{{_('Username')}}" value="{{ username }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">{{_('Password')}}</label> <label for="password">{{_('Password')}}</label>
<input type="password" class="form-control" id="password" name="password" placeholder="{{_('Password')}}"> <input type="password" class="form-control" id="password" name="password" placeholder="{{_('Password')}}" value="{{ password }}">
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label> <label>

View File

@ -21,7 +21,7 @@
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group">
<label for="password">{{_('Password')}}</label> <label for="password">{{_('Password')}}</label>
<input type="password" class="form-control" name="password" id="password" value="" autocomplete="off"> <input type="password" class="form-control" name="password" id="password" data-lang="{{ current_user.locale }}" data-verify="{{ config.config_password_policy }}" {% if config.config_password_policy %} data-min={{ config.config_password_min_length }} data-special={{ config.config_password_special }} data-upper={{ config.config_password_upper }} data-lower={{ config.config_password_lower }} data-number={{ config.config_password_number }}{% endif %} value="" autocomplete="off">
</div> </div>
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group">
@ -175,5 +175,9 @@
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-table-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script> <script src="{{ url_for('static', filename='js/libs/bootstrap-table/bootstrap-editable.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/pwstrength/i18next.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/pwstrength/i18nextHttpBackend.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/libs/pwstrength/pwstrength-bootstrap.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/password.js') }}"></script>
<script src="{{ url_for('static', filename='js/table.js') }}"></script> <script src="{{ url_for('static', filename='js/table.js') }}"></script>
{% endblock %} {% endblock %}

View File

@ -55,6 +55,7 @@ from werkzeug.security import generate_password_hash
from . import constants, logger from . import constants, logger
log = logger.create() log = logger.create()
session = None session = None
@ -817,7 +818,7 @@ def init_db_thread():
return Session() return Session()
def init_db(app_db_path, user_credentials=None): def init_db(app_db_path):
# Open session for database connection # Open session for database connection
global session global session
global app_DB_path global app_DB_path
@ -838,6 +839,7 @@ def init_db(app_db_path, user_credentials=None):
create_admin_user(session) create_admin_user(session)
create_anonymous_user(session) create_anonymous_user(session)
def password_change(user_credentials=None):
if user_credentials: if user_credentials:
username, password = user_credentials.split(':', 1) username, password = user_credentials.split(':', 1)
user = session.query(User).filter(func.lower(User.name) == username.lower()).first() user = session.query(User).filter(func.lower(User.name) == username.lower()).first()
@ -845,7 +847,12 @@ def init_db(app_db_path, user_credentials=None):
if not password: if not password:
print("Empty password is not allowed") print("Empty password is not allowed")
sys.exit(4) sys.exit(4)
user.password = generate_password_hash(password) try:
from .helper import valid_password
user.password = generate_password_hash(valid_password(password))
except Exception:
print("Password doesn't comply with password validation rules")
sys.exit(4)
if session_commit() == "": if session_commit() == "":
print("Password for user '{}' changed".format(username)) print("Password for user '{}' changed".format(username))
sys.exit(0) sys.exit(0)

View File

@ -401,7 +401,7 @@ class Updater(threading.Thread):
os.sep + 'app.db', os.sep + 'calibre-web.log1', os.sep + 'calibre-web.log2', os.sep + 'gdrive.db', os.sep + 'app.db', os.sep + 'calibre-web.log1', os.sep + 'calibre-web.log2', os.sep + 'gdrive.db',
os.sep + 'vendor', os.sep + 'calibre-web.log', os.sep + '.git', os.sep + 'client_secrets.json', os.sep + 'vendor', os.sep + 'calibre-web.log', os.sep + '.git', os.sep + 'client_secrets.json',
os.sep + 'gdrive_credentials', os.sep + 'settings.yaml', os.sep + 'venv', os.sep + 'virtualenv', os.sep + 'gdrive_credentials', os.sep + 'settings.yaml', os.sep + 'venv', os.sep + 'virtualenv',
os.sep + 'access.log', os.sep + 'access.log1', os.sep + 'access.log2', os.sep + 'access.log', os.sep + 'access.log1', os.sep + 'access.log2', os.sep + '.key',
os.sep + '.calibre-web.log.swp', os.sep + '_sqlite3.so', os.sep + 'cps' + os.sep + '.HOMEDIR', os.sep + '.calibre-web.log.swp', os.sep + '_sqlite3.so', os.sep + 'cps' + os.sep + '.HOMEDIR',
os.sep + 'gmail.json', os.sep + 'exclude.txt', os.sep + 'cps' + os.sep + 'cache' os.sep + 'gmail.json', os.sep + 'exclude.txt', os.sep + 'cps' + os.sep + 'cache'
] ]

View File

@ -23,7 +23,7 @@ from werkzeug.security import check_password_hash
from flask_login import login_required, login_user from flask_login import login_required, login_user
from flask import request, Response from flask import request, Response
from . import lm, ub, config, constants, services, logger from . import lm, ub, config, constants, services, logger, limiter
log = logger.create() log = logger.create()
@ -52,6 +52,7 @@ def requires_basic_auth_if_no_ano(f):
login_result, error = services.ldap.bind_user(auth.username, auth.password) login_result, error = services.ldap.bind_user(auth.username, auth.password)
if login_result: if login_result:
user = _fetch_user_by_name(auth.username) user = _fetch_user_by_name(auth.username)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
login_user(user) login_user(user)
return f(*args, **kwargs) return f(*args, **kwargs)
elif login_result is not None: elif login_result is not None:
@ -65,8 +66,10 @@ def requires_basic_auth_if_no_ano(f):
def _load_user_from_auth_header(username, password): def _load_user_from_auth_header(username, password):
limiter.check()
user = _fetch_user_by_name(username) user = _fetch_user_by_name(username)
if bool(user and check_password_hash(str(user.password), password)): if bool(user and check_password_hash(str(user.password), password)) and user.name != "Guest":
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
login_user(user) login_user(user)
return user return user
else: else:
@ -101,6 +104,7 @@ def load_user_from_reverse_proxy_header(req):
if rp_header_username: if rp_header_username:
user = _fetch_user_by_name(rp_header_username) user = _fetch_user_by_name(rp_header_username)
if user: if user:
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
login_user(user) login_user(user)
return user return user
return None return None

View File

@ -30,6 +30,8 @@ from flask import session as flask_session
from flask_babel import gettext as _ from flask_babel import gettext as _
from flask_babel import get_locale from flask_babel import get_locale
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from flask_limiter import RateLimitExceeded
from flask_limiter.util import get_remote_address
from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
from sqlalchemy.sql.expression import text, func, false, not_, and_, or_ from sqlalchemy.sql.expression import text, func, false, not_, and_, or_
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@ -46,7 +48,7 @@ from .gdriveutils import getFileFromEbooksFolder, do_gdrive_download
from .helper import check_valid_domain, check_email, check_username, \ from .helper import check_valid_domain, check_email, check_username, \
get_book_cover, get_series_cover_thumbnail, get_download_link, send_mail, generate_random_password, \ get_book_cover, get_series_cover_thumbnail, get_download_link, send_mail, generate_random_password, \
send_registration_mail, check_send_to_ereader, check_read_formats, tags_filters, reset_password, valid_email, \ send_registration_mail, check_send_to_ereader, check_read_formats, tags_filters, reset_password, valid_email, \
edit_book_read_status edit_book_read_status, valid_password
from .pagination import Pagination from .pagination import Pagination
from .redirect import redirect_back from .redirect import redirect_back
from .babel import get_available_locale from .babel import get_available_locale
@ -54,9 +56,11 @@ from .usermanagement import login_required_if_no_ano
from .kobo_sync_status import remove_synced_book from .kobo_sync_status import remove_synced_book
from .render_template import render_title_template from .render_template import render_title_template
from .kobo_sync_status import change_archived_books from .kobo_sync_status import change_archived_books
from . import limiter
from .services.worker import WorkerThread from .services.worker import WorkerThread
from .tasks_status import render_task_status from .tasks_status import render_task_status
feature_support = { feature_support = {
'ldap': bool(services.ldap), 'ldap': bool(services.ldap),
'goodreads': bool(services.goodreads_support), 'goodreads': bool(services.goodreads_support),
@ -1230,19 +1234,23 @@ def send_to_ereader(book_id, book_format, convert):
# ################################### Login Logout ################################################################## # ################################### Login Logout ##################################################################
@web.route('/register', methods=['POST'])
@web.route('/register', methods=['GET', 'POST']) @limiter.limit("40/day", key_func=get_remote_address)
def register(): @limiter.limit("3/minute", key_func=get_remote_address)
def register_post():
if not config.config_public_reg: if not config.config_public_reg:
abort(404) abort(404)
to_save = request.form.to_dict()
try:
limiter.check()
except RateLimitExceeded:
flash(_(u"Please wait one minute to register next user"), category="error")
return render_title_template('register.html', config=config, title=_("Register"), page="register")
if current_user is not None and current_user.is_authenticated: if current_user is not None and current_user.is_authenticated:
return redirect(url_for('web.index')) return redirect(url_for('web.index'))
if not config.get_mail_server_configured(): if not config.get_mail_server_configured():
flash(_("Oops! Email server is not configured, please contact your administrator."), category="error") flash(_("Oops! Email server is not configured, please contact your administrator."), category="error")
return render_title_template('register.html', title=_("Register"), page="register") return render_title_template('register.html', title=_("Register"), page="register")
if request.method == "POST":
to_save = request.form.to_dict()
nickname = to_save.get("email", "").strip() if config.config_register_email else to_save.get('name') nickname = to_save.get("email", "").strip() if config.config_register_email else to_save.get('name')
if not nickname or not to_save.get("email"): if not nickname or not to_save.get("email"):
flash(_("Oops! Please complete all fields."), category="error") flash(_("Oops! Please complete all fields."), category="error")
@ -1258,7 +1266,7 @@ def register():
if check_valid_domain(email): if check_valid_domain(email):
content.name = nickname content.name = nickname
content.email = email content.email = email
password = generate_random_password() password = generate_random_password(config.config_password_min_length)
content.password = generate_password_hash(password) content.password = generate_password_hash(password)
content.role = config.config_default_role content.role = config.config_default_role
content.locale = config.config_default_locale content.locale = config.config_default_locale
@ -1275,79 +1283,35 @@ def register():
return render_title_template('register.html', title=_("Register"), page="register") return render_title_template('register.html', title=_("Register"), page="register")
else: else:
flash(_("Oops! Your Email is not allowed."), category="error") flash(_("Oops! Your Email is not allowed."), category="error")
log.warning('Registering failed for user "{}" Email: {}'.format(nickname, log.warning('Registering failed for user "{}" Email: {}'.format(nickname, to_save.get("email","")))
to_save.get("email","")))
return render_title_template('register.html', title=_("Register"), page="register") return render_title_template('register.html', title=_("Register"), page="register")
flash(_("Success! Confirmation Email has been sent."), category="success") flash(_("Success! Confirmation Email has been sent."), category="success")
return redirect(url_for('web.login')) return redirect(url_for('web.login'))
@web.route('/register', methods=['GET'])
def register():
if not config.config_public_reg:
abort(404)
if current_user is not None and current_user.is_authenticated:
return redirect(url_for('web.index'))
if not config.get_mail_server_configured():
flash(_("Oops! Email server is not configured, please contact your administrator."), category="error")
return render_title_template('register.html', title=_("Register"), page="register")
if feature_support['oauth']: if feature_support['oauth']:
register_user_with_oauth() register_user_with_oauth()
return render_title_template('register.html', config=config, title=_("Register"), page="register") return render_title_template('register.html', config=config, title=_("Register"), page="register")
@web.route('/login', methods=['GET', 'POST']) def handle_login_user(user, remember, message, category):
def login(): login_user(user, remember=remember)
if current_user is not None and current_user.is_authenticated:
return redirect(url_for('web.index'))
if config.config_login_type == constants.LOGIN_LDAP and not services.ldap:
log.error("Cannot activate LDAP authentication")
flash(_("Oops! Cannot activate LDAP authentication"), category="error")
if request.method == "POST":
form = request.form.to_dict()
user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == form['username'].strip().lower()) \
.first()
if config.config_login_type == constants.LOGIN_LDAP and services.ldap and user and form['password'] != "":
login_result, error = services.ldap.bind_user(form['username'], form['password'])
if login_result:
login_user(user, remember=bool(form.get('remember_me')))
ub.store_user_session() ub.store_user_session()
log.debug("You are now logged in as: '{}'".format(user.name)) flash(message, category=category)
flash(_("Success! You are now logged in as: %(nickname)s", nickname=user.name), [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
category="success")
return redirect_back(url_for("web.index")) return redirect_back(url_for("web.index"))
elif login_result is None and user and check_password_hash(str(user.password), form['password']) \
and user.name != "Guest":
login_user(user, remember=bool(form.get('remember_me')))
ub.store_user_session()
log.info("Local Fallback Login as: '{}'".format(user.name))
flash(_("Fallback Login as: %(nickname)s, LDAP Server not reachable, or user not known",
nickname=user.name),
category="warning")
return redirect_back(url_for("web.index"))
elif login_result is None:
log.info(error)
flash(_("Oops! Login Failed: %(message)s", message=error), category="error")
else:
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
log.warning('LDAP Login failed for user "%s" IP-address: %s', form['username'], ip_address)
flash(_("Oops! Invalid Username or Password."), category="error")
else:
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if form.get('forgot', "") == 'forgot':
if user is not None and user.name != "Guest":
ret, __ = reset_password(user.id)
if ret == 1:
flash(_("Success! New Password was sent to your Email."), category="info")
log.info('Password reset for user "%s" IP-address: %s', form['username'], ip_address)
else:
log.error("An unknown error occurred. Please try again later")
flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
else:
flash(_("Oops! Please enter a valid username to reset password"), category="error")
log.warning('Username missing for password reset IP-address: %s', ip_address)
else:
if user and check_password_hash(str(user.password), form['password']) and user.name != "Guest":
login_user(user, remember=bool(form.get('remember_me')))
ub.store_user_session()
log.debug("You are now logged in as: '%s'", user.name)
flash(_("Success! You are now logged in as: %(nickname)s", nickname=user.name), category="success")
config.config_is_initial = False
return redirect_back(url_for("web.index"))
else:
log.warning('Login failed for user "{}" IP-address: {}'.format(form['username'], ip_address))
flash(_("Oops! Invalid Username or Password."), category="error")
def render_login(username="", password=""):
next_url = request.args.get('next', default=url_for("web.index"), type=str) next_url = request.args.get('next', default=url_for("web.index"), type=str)
if url_for("web.logout") == next_url: if url_for("web.logout") == next_url:
next_url = url_for("web.index") next_url = url_for("web.index")
@ -1355,10 +1319,91 @@ def login():
title=_("Login"), title=_("Login"),
next_url=next_url, next_url=next_url,
config=config, config=config,
username=username,
password=password,
oauth_check=oauth_check, oauth_check=oauth_check,
mail=config.get_mail_server_configured(), page="login") mail=config.get_mail_server_configured(), page="login")
@web.route('/login', methods=['GET'])
def login():
if current_user is not None and current_user.is_authenticated:
return redirect(url_for('web.index'))
if config.config_login_type == constants.LOGIN_LDAP and not services.ldap:
log.error(u"Cannot activate LDAP authentication")
flash(_(u"Cannot activate LDAP authentication"), category="error")
return render_login()
@web.route('/login', methods=['POST'])
@limiter.limit("40/day", key_func=lambda: request.form.get('username', "").strip().lower())
@limiter.limit("3/minute", key_func=lambda: request.form.get('username', "").strip().lower())
def login_post():
form = request.form.to_dict()
try:
limiter.check()
except RateLimitExceeded:
flash(_(u"Please wait one minute before next login"), category="error")
return render_login(form.get("username", ""), form.get("password", ""))
if current_user is not None and current_user.is_authenticated:
return redirect(url_for('web.index'))
if config.config_login_type == constants.LOGIN_LDAP and not services.ldap:
log.error(u"Cannot activate LDAP authentication")
flash(_(u"Cannot activate LDAP authentication"), category="error")
user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == form.get('username', "").strip().lower()) \
.first()
remember_me = bool(form.get('remember_me'))
if config.config_login_type == constants.LOGIN_LDAP and services.ldap and user and form['password'] != "":
login_result, error = services.ldap.bind_user(form['username'], form['password'])
if login_result:
log.debug(u"You are now logged in as: '{}'".format(user.name))
return handle_login_user(user,
remember_me,
_(u"you are now logged in as: '%(nickname)s'", nickname=user.name),
"success")
elif login_result is None and user and check_password_hash(str(user.password), form['password']) \
and user.name != "Guest":
log.info("Local Fallback Login as: '{}'".format(user.name))
return handle_login_user(user,
remember_me,
_(u"Fallback Login as: '%(nickname)s', "
u"LDAP Server not reachable, or user not known", nickname=user.name),
"warning")
elif login_result is None:
log.info(error)
flash(_(u"Could not login: %(message)s", message=error), category="error")
else:
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
log.warning('LDAP Login failed for user "%s" IP-address: %s', form['username'], ip_address)
flash(_(u"Wrong Username or Password"), category="error")
else:
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
if form.get('forgot', "") == 'forgot':
if user is not None and user.name != "Guest":
ret, __ = reset_password(user.id)
if ret == 1:
flash(_(u"New Password was send to your email address"), category="info")
log.info('Password reset for user "%s" IP-address: %s', form['username'], ip_address)
else:
log.error(u"An unknown error occurred. Please try again later")
flash(_(u"An unknown error occurred. Please try again later."), category="error")
else:
flash(_(u"Please enter valid username to reset password"), category="error")
log.warning('Username missing for password reset IP-address: %s', ip_address)
else:
if user and check_password_hash(str(user.password), form['password']) and user.name != "Guest":
config.config_is_initial = False
log.debug(u"You are now logged in as: '{}'".format(user.name))
return handle_login_user(user,
remember_me,
_(u"You are now logged in as: '%(nickname)s'", nickname=user.name),
"success")
else:
log.warning('Login failed for user "{}" IP-address: {}'.format(form['username'], ip_address))
flash(_(u"Wrong Username or Password"), category="error")
return render_login(form.get("username", ""), form.get("password", ""))
@web.route('/logout') @web.route('/logout')
@login_required @login_required
def logout(): def logout():
@ -1375,10 +1420,10 @@ def logout():
def change_profile(kobo_support, local_oauth_check, oauth_status, translations, languages): def change_profile(kobo_support, local_oauth_check, oauth_status, translations, languages):
to_save = request.form.to_dict() to_save = request.form.to_dict()
current_user.random_books = 0 current_user.random_books = 0
if current_user.role_passwd() or current_user.role_admin():
if to_save.get("password"):
current_user.password = generate_password_hash(to_save.get("password"))
try: try:
if current_user.role_passwd() or current_user.role_admin():
if to_save.get("password", "") != "":
current_user.password = generate_password_hash(to_save.get("password"))
if to_save.get("kindle_mail", current_user.kindle_mail) != current_user.kindle_mail: if to_save.get("kindle_mail", current_user.kindle_mail) != current_user.kindle_mail:
current_user.kindle_mail = valid_email(to_save.get("kindle_mail")) current_user.kindle_mail = valid_email(to_save.get("kindle_mail"))
new_email = valid_email(to_save.get("email", current_user.email)) new_email = valid_email(to_save.get("email", current_user.email))
@ -1405,6 +1450,7 @@ def change_profile(kobo_support, local_oauth_check, oauth_status, translations,
flash(str(ex), category="error") flash(str(ex), category="error")
return render_title_template("user_edit.html", return render_title_template("user_edit.html",
content=current_user, content=current_user,
config=config,
translations=translations, translations=translations,
profile=1, profile=1,
languages=languages, languages=languages,
@ -1456,6 +1502,7 @@ def profile():
profile=1, profile=1,
languages=languages, languages=languages,
content=current_user, content=current_user,
config=config,
kobo_support=kobo_support, kobo_support=kobo_support,
title=_("%(name)s's Profile", name=current_user.name), title=_("%(name)s's Profile", name=current_user.name),
page="me", page="me",

View File

@ -18,3 +18,4 @@ lxml>=3.8.0,<5.0.0
flask-wtf>=0.14.2,<1.2.0 flask-wtf>=0.14.2,<1.2.0
chardet>=3.0.0,<4.1.0 chardet>=3.0.0,<4.1.0
advocate>=1.0.0,<1.1.0 advocate>=1.0.0,<1.1.0
Flask-Limiter>=2.3.0,<3.3.0

View File

@ -58,6 +58,7 @@ install_requires =
flask-wtf>=0.14.2,<1.2.0 flask-wtf>=0.14.2,<1.2.0
chardet>=3.0.0,<4.1.0 chardet>=3.0.0,<4.1.0
advocate>=1.0.0,<1.1.0 advocate>=1.0.0,<1.1.0
Flask-Limiter>=2.3.0,<3.2.0
[options.extras_require] [options.extras_require]

File diff suppressed because it is too large Load Diff