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
client_secrets.json
gmail.json
/.key

2
cps.py
View File

@ -21,7 +21,7 @@ import os
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__))
sys.path.insert(0, path)

View File

@ -41,6 +41,11 @@ from . import config_sql
from . import cache_buster
from . import ub, db
try:
from flask_limiter import Limiter
limiter_present = True
except ImportError:
limiter_present = False
try:
from flask_wtf.csrf import CSRFProtect
wtf_present = True
@ -81,7 +86,7 @@ app.config.update(
lm = MyLoginManager()
config = config_sql._ConfigSQL()
config = config_sql.ConfigSQL()
cli_param = CliParameter()
@ -96,33 +101,36 @@ web_server = WebServer()
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():
lm.login_view = 'web.login'
lm.anonymous_user = ub.Anonymous
lm.session_protection = 'strong'
if csrf:
csrf.init_app(app)
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
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)
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
calibre_db.init_db()
config_sql.load_configuration(ub.session, encrypt_key)
config.init_config(ub.session, encrypt_key, cli_param)
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()
if error:
log.error(error)
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):
log.info(
'*** 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" ***')
web_server.stop(True)
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):
log.info('*** "{}" version does not meet the requirements. '
'Should: {}, Found: {}, please consider installing required version ***'
@ -150,7 +174,6 @@ def create_app():
if os.environ.get('FLASK_DEBUG'):
cache_buster.init_cache_busting(app)
log.info('Starting Calibre Web...')
Principal(app)
lm.init_app(app)
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)
if services.goodreads_support:
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.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
from .schedule import register_scheduled_tasks, register_startup_tasks
register_scheduled_tasks(config.schedule_reconnect)

View File

@ -22,7 +22,6 @@
import os
import re
import base64
import json
import operator
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:
logout_user()
g.constants = constants
# g.user = current_user
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION','')
g.allow_registration = config.config_public_reg
g.allow_anonymous = config.config_anonbrowse
@ -116,6 +114,7 @@ def before_request():
'admin.simulatedbchange',
'admin.db_configuration',
'web.login',
'web.login_post',
'web.logout',
'admin.load_dialogtexts',
'admin.ajax_pathchooser'):
@ -214,12 +213,12 @@ def admin():
commit = version['version']
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")
t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60)
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,
schedule_duration=schedule_duration,
title=_("Admin page"), page="admin")
@ -1084,7 +1083,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() 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):
@ -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_key_path")
_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
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()
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_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'))
else:
if not config.config_ldap_serv_username:
@ -1255,7 +1254,7 @@ def new_user():
kobo_support=kobo_support, registered_oauth=oauth_check)
@admi.route("/admin/mailsettings")
@admi.route("/admin/mailsettings", methods=["GET"])
@login_required
@admin_required
def edit_mailsettings():
@ -1288,7 +1287,7 @@ def update_mailsettings():
else:
_config_int(to_save, "mail_port")
_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.mail_server = to_save.get('mail_server', "").strip()
config.mail_from = to_save.get('mail_from', "").strip()
@ -1348,7 +1347,7 @@ def update_scheduledtasks():
error = False
to_save = request.form.to_dict()
if 0 <= int(to_save.get("schedule_start_time")) <= 23:
_config_int(to_save, "schedule_start_time")
_config_int( to_save, "schedule_start_time")
else:
flash(_("Invalid start time for task specified"), category="error")
error = True
@ -1771,10 +1770,10 @@ def _configuration_update_helper():
# Goodreads configuration
_config_checkbox(to_save, "config_use_goodreads")
_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:
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_int(to_save, "config_updatechannel")
@ -1787,10 +1786,25 @@ def _configuration_update_helper():
if config.config_login_type == constants.LOGIN_OAUTH:
reboot_required |= _configuration_oauth_helper(to_save)
# logfile configuration
reboot, message = _configuration_logfile_helper(to_save)
if message:
return message
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
_config_string(to_save, "config_rarfile_location")
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.role = constants.selected_roles(to_save)
content.password = generate_password_hash(to_save["password"])
try:
if not to_save["name"] or not to_save["email"] or not to_save["password"]:
log.info("Missing entries on new user")
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"])
# Query username, if not existing, change
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))
flash(_("No admin user remaining, can't remove admin role"), category="error")
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_')]
sidebar, __ = get_sidebar_config()
@ -1982,6 +1988,15 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
if to_save.get("locale"):
content.locale = to_save["locale"]
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))
if not new_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.exc import OperationalError
from sqlalchemy.sql.expression import text
from sqlalchemy import exists
from cryptography.fernet import Fernet
import cryptography.exceptions
from base64 import urlsafe_b64decode
try:
# Compatibility with sqlalchemy 2.0
from sqlalchemy.orm import declarative_base
@ -56,7 +60,8 @@ class _Settings(_Base):
mail_port = Column(Integer, default=25)
mail_use_ssl = Column(SmallInteger, default=0)
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_size = Column(Integer, default=25*1024*1024)
mail_server_type = Column(SmallInteger, default=0)
@ -75,7 +80,6 @@ class _Settings(_Base):
config_authors_max = 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_mature_content_tags = Column(String, default='')
config_theme = Column(Integer, default=0)
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_goodreads_api_key = Column(String)
config_goodreads_api_secret_e = Column(String)
config_goodreads_api_secret = Column(String)
config_register_email = Column(Boolean, default=False)
config_login_type = Column(Integer, default=0)
@ -117,7 +122,8 @@ class _Settings(_Base):
config_ldap_port = Column(SmallInteger, default=389)
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_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_cacert_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_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):
return self.__class__.__name__
# Class holds all application specific settings in calibre-web
class _ConfigSQL(object):
class ConfigSQL(object):
# pylint: disable=no-member
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._settings = None
self.db_configured = None
self.config_calibre_dir = None
self.load()
self._fernet = Fernet(secret_key)
self.cli = cli
self.load()
change = False
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
@ -293,10 +309,10 @@ class _ConfigSQL(object):
setattr(self, field, new_value)
return True
def toDict(self):
def to_dict(self):
storage = {}
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
return storage
@ -310,6 +326,12 @@ class _ConfigSQL(object):
column = s.__class__.__dict__.get(k)
if column.default is not None:
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)
have_metadata_db = bool(self.config_calibre_dir)
@ -332,16 +354,20 @@ class _ConfigSQL(object):
except OperationalError as e:
log.error('Database error: %s', e)
self._session.rollback()
self.__dict__["dirty"] = list()
def save(self):
"""Apply all configuration values to the underlying storage."""
s = self._read_from_storage() # type: _Settings
for k, v in self.__dict__.items():
for k in self.dirty:
if k[0] == '_':
continue
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")
self._session.merge(s)
@ -357,7 +383,6 @@ class _ConfigSQL(object):
log.error(error)
log.warning("invalidating configuration")
self.db_configured = False
# self.config_calibre_dir = None
self.save()
def store_calibre_uuid(self, calibre_db, Library_table):
@ -369,8 +394,40 @@ class _ConfigSQL(object):
except AttributeError:
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
for column_name, column in orm_class.__dict__.items():
@ -446,22 +503,18 @@ def autodetect_kepubify_binary():
return ""
def _migrate_database(session):
def _migrate_database(session, secret_key):
# make sure the table is created, if it does not exist
_Base.metadata.create_all(session.bind)
_migrate_table(session, _Settings)
_migrate_table(session, _Settings, secret_key)
_migrate_table(session, _Flask_Settings)
def load_configuration(conf, session, cli):
_migrate_database(session)
def load_configuration(session, secret_key):
_migrate_database(session, secret_key)
if not session.query(_Settings).count():
session.add(_Settings())
session.commit()
# conf = _ConfigSQL()
conf.init_config(session, cli)
# return conf
def get_flask_session_key(_session):
@ -471,3 +524,25 @@ def get_flask_session_key(_session):
_session.add(flask_settings)
_session.commit()
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)
memory_zip = BytesIO()
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))
for fp in file_list:
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/>.
import os
import random
import io
import mimetypes
import re
@ -612,7 +613,7 @@ def reset_password(user_id):
if not config.get_mail_server_configured():
return 2, None
try:
password = generate_random_password()
password = generate_random_password(config.config_password_min_length)
existing_user.password = generate_password_hash(password)
ub.session.commit()
send_registration_mail(existing_user.email, existing_user.name, password, True)
@ -621,11 +622,35 @@ def reset_password(user_id):
ub.session.rollback()
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!@#$%&*()?"
passlen = 8
return "".join(s[c % len(s)] for c in os.urandom(passlen))
passlen = min_length
return "".join(s[c % len(s)] for c in os.urandom(passlen))'''
def uniq(inpt):
@ -664,6 +689,23 @@ def valid_email(email):
raise Exception(_("Invalid Email address format"))
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 #################################

View File

@ -64,11 +64,12 @@ from datetime import datetime
from os import urandom
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_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
log = logger.create()
@ -151,6 +152,10 @@ def requires_kobo_auth(f):
def inner(*args, **kwargs):
auth_token = get_auth_token()
if auth_token is not None:
try:
limiter.check()
except RateLimitExceeded:
return abort(429)
user = (
ub.session.query(ub.User)
.join(ub.RemoteAuthToken)
@ -159,6 +164,7 @@ def requires_kobo_auth(f):
)
if user is not None:
login_user(user)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
return f(*args, **kwargs)
log.debug("Received Kobo request without a recognizable auth token.")
return abort(401)

View File

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

View File

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

View File

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

View File

@ -44,15 +44,15 @@ def init_app(app, config):
app.config['LDAP_SCHEMA'] = 'ldap'
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
if config.config_ldap_serv_password is None:
config.config_ldap_serv_password = ''
app.config['LDAP_PASSWORD'] = base64.b64decode(config.config_ldap_serv_password)
if config.config_ldap_serv_password_e is None:
config.config_ldap_serv_password_e = ''
app.config['LDAP_PASSWORD'] = config.config_ldap_serv_password_e
else:
app.config['LDAP_PASSWORD'] = base64.b64decode("")
app.config['LDAP_PASSWORD'] = ""
app.config['LDAP_USERNAME'] = config.config_ldap_serv_username
else:
app.config['LDAP_USERNAME'] = ""
app.config['LDAP_PASSWORD'] = base64.b64decode("")
app.config['LDAP_PASSWORD'] = ""
if bool(config.config_ldap_cert_path):
app.config['LDAP_CUSTOM_OPTIONS'].update({
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:-ms-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 TaskActions (value, row) {
var cancellableStats = [0, 1, 2];
var cancellableStats = [0, 2];
if (row.task_id && row.is_cancellable && cancellableStats.includes(row.stat)) {
return [
"<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)
if use_ssl == 1:
self.asyncSMTP.starttls()
if self.settings["mail_password"]:
self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password"]))
if self.settings["mail_password_e"]:
self.asyncSMTP.login(str(self.settings["mail_login"]), str(self.settings["mail_password_e"]))
# Convert message to something to send
fp = StringIO()

View File

@ -22,8 +22,8 @@ from urllib.request import urlopen
from lxml import etree
from html import escape
from cps import config, db, fs, gdriveutils, logger, ub
from cps.services.worker import CalibreTask, STAT_CANCELLED, STAT_ENDED
from cps import config, db, gdriveutils, logger
from cps.services.worker import CalibreTask
from flask_babel import lazy_gettext as N_
OPF_NAMESPACE = "http://www.idpf.org/2007/opf"
@ -74,7 +74,10 @@ class TaskBackupMetadata(CalibreTask):
def backup_metadata(self):
try:
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)
i = 0
for backup in metadata_backup:
@ -149,7 +152,7 @@ class TaskBackupMetadata(CalibreTask):
package.set("unique-identifier", "uuid_id")
package.set("version", "2.0")
# generate metadata element and all subelements of it
# generate metadata element and all sub elements of it
metadata = etree.SubElement(package, "metadata", nsmap=NSMAP)
identifier = etree.SubElement(metadata, PURL + "identifier", id="calibre_id", nsmap=NSMAP)
identifier.set(OPF + "scheme", "calibre")
@ -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;")
try:
with open(book_metadata_filepath, 'wb') as f:
# f.write(doc)
doc.write(f, xml_declaration=True, encoding='utf-8', pretty_print=True)
except Exception:
# ToDo: Folder not writeable error
pass
@property
def name(self):
return "Metadata backup"

View File

@ -61,27 +61,27 @@
<div class="col">
<h2>{{_('Email Server Settings')}}</h2>
{% 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="row">
<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 class="row">
<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 class="row">
<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 class="row">
<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 class="row">
<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>
{% else %}
@ -92,7 +92,7 @@
</div>
<div class="row">
<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>
{% 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">
</div>
<div class="form-group">
<label for="config_goodreads_api_secret">{{_('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">
<label for="config_goodreads_api_secret_e">{{_('Goodreads API Secret')}}</label>
<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>
{% endif %}
@ -245,8 +245,8 @@
</div>
<div data-related="ldap-auth-password-2">
<div class="form-group">
<label for="config_ldap_serv_password">{{_('LDAP Administrator Password')}}</label>
<input type="password" class="form-control" id="config_ldap_serv_password" name="config_ldap_serv_password" value="" autocomplete="off">
<label for="config_ldap_serv_password_e">{{_('LDAP Administrator Password')}}</label>
<input type="password" class="form-control" id="config_ldap_serv_password_e" name="config_ldap_serv_password_e" value="" autocomplete="off">
</div>
</div>
<div class="form-group">
@ -353,6 +353,57 @@
</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 class="col-sm-12">
<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}}">
</div>
<div class="form-group">
<label for="mail_password">{{_('SMTP Password')}}</label>
<input type="password" class="form-control" name="mail_password" id="mail_password" value="{{content.mail_password}}">
<label for="mail_password_e">{{_('SMTP Password')}}</label>
<input type="password" class="form-control" name="mail_password_e" id="mail_password_e" value="{{content.mail_password_e}}">
</div>
<div class="form-group">
<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() }}">
<div class="form-group">
<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 class="form-group">
<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 class="checkbox">
<label>

View File

@ -21,7 +21,7 @@
{% endif %}
<div class="form-group">
<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>
{% endif %}
<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-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>
{% endblock %}

View File

@ -55,6 +55,7 @@ from werkzeug.security import generate_password_hash
from . import constants, logger
log = logger.create()
session = None
@ -817,7 +818,7 @@ def init_db_thread():
return Session()
def init_db(app_db_path, user_credentials=None):
def init_db(app_db_path):
# Open session for database connection
global session
global app_DB_path
@ -838,6 +839,7 @@ def init_db(app_db_path, user_credentials=None):
create_admin_user(session)
create_anonymous_user(session)
def password_change(user_credentials=None):
if user_credentials:
username, password = user_credentials.split(':', 1)
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:
print("Empty password is not allowed")
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() == "":
print("Password for user '{}' changed".format(username))
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 + 'vendor', os.sep + 'calibre-web.log', os.sep + '.git', os.sep + 'client_secrets.json',
os.sep + 'gdrive_credentials', os.sep + 'settings.yaml', os.sep + 'venv', os.sep + 'virtualenv',
os.sep + 'access.log', os.sep + 'access.log1', os.sep + 'access.log2',
os.sep + 'access.log', os.sep + 'access.log1', os.sep + 'access.log2', os.sep + '.key',
os.sep + '.calibre-web.log.swp', os.sep + '_sqlite3.so', os.sep + 'cps' + os.sep + '.HOMEDIR',
os.sep + 'gmail.json', os.sep + 'exclude.txt', os.sep + 'cps' + os.sep + 'cache'
]

View File

@ -23,7 +23,7 @@ from werkzeug.security import check_password_hash
from flask_login import login_required, login_user
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()
@ -52,6 +52,7 @@ def requires_basic_auth_if_no_ano(f):
login_result, error = services.ldap.bind_user(auth.username, auth.password)
if login_result:
user = _fetch_user_by_name(auth.username)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
login_user(user)
return f(*args, **kwargs)
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):
limiter.check()
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)
return user
else:
@ -101,6 +104,7 @@ def load_user_from_reverse_proxy_header(req):
if rp_header_username:
user = _fetch_user_by_name(rp_header_username)
if user:
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
login_user(user)
return user
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 get_locale
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.sql.expression import text, func, false, not_, and_, or_
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, \
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, \
edit_book_read_status
edit_book_read_status, valid_password
from .pagination import Pagination
from .redirect import redirect_back
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 .render_template import render_title_template
from .kobo_sync_status import change_archived_books
from . import limiter
from .services.worker import WorkerThread
from .tasks_status import render_task_status
feature_support = {
'ldap': bool(services.ldap),
'goodreads': bool(services.goodreads_support),
@ -1230,19 +1234,23 @@ def send_to_ereader(book_id, book_format, convert):
# ################################### Login Logout ##################################################################
@web.route('/register', methods=['GET', 'POST'])
def register():
@web.route('/register', methods=['POST'])
@limiter.limit("40/day", key_func=get_remote_address)
@limiter.limit("3/minute", key_func=get_remote_address)
def register_post():
if not config.config_public_reg:
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:
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 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')
if not nickname or not to_save.get("email"):
flash(_("Oops! Please complete all fields."), category="error")
@ -1258,7 +1266,7 @@ def register():
if check_valid_domain(email):
content.name = nickname
content.email = email
password = generate_random_password()
password = generate_random_password(config.config_password_min_length)
content.password = generate_password_hash(password)
content.role = config.config_default_role
content.locale = config.config_default_locale
@ -1275,79 +1283,35 @@ def register():
return render_title_template('register.html', title=_("Register"), page="register")
else:
flash(_("Oops! Your Email is not allowed."), category="error")
log.warning('Registering failed for user "{}" Email: {}'.format(nickname,
to_save.get("email","")))
log.warning('Registering failed for user "{}" Email: {}'.format(nickname, to_save.get("email","")))
return render_title_template('register.html', title=_("Register"), page="register")
flash(_("Success! Confirmation Email has been sent."), category="success")
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']:
register_user_with_oauth()
return render_title_template('register.html', config=config, title=_("Register"), page="register")
@web.route('/login', methods=['GET', 'POST'])
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("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')))
def handle_login_user(user, remember, message, category):
login_user(user, remember=remember)
ub.store_user_session()
log.debug("You are now logged in as: '{}'".format(user.name))
flash(_("Success! You are now logged in as: %(nickname)s", nickname=user.name),
category="success")
flash(message, category=category)
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
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)
if url_for("web.logout") == next_url:
next_url = url_for("web.index")
@ -1355,10 +1319,91 @@ def login():
title=_("Login"),
next_url=next_url,
config=config,
username=username,
password=password,
oauth_check=oauth_check,
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')
@login_required
def logout():
@ -1375,10 +1420,10 @@ def logout():
def change_profile(kobo_support, local_oauth_check, oauth_status, translations, languages):
to_save = request.form.to_dict()
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:
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:
current_user.kindle_mail = valid_email(to_save.get("kindle_mail"))
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")
return render_title_template("user_edit.html",
content=current_user,
config=config,
translations=translations,
profile=1,
languages=languages,
@ -1456,6 +1502,7 @@ def profile():
profile=1,
languages=languages,
content=current_user,
config=config,
kobo_support=kobo_support,
title=_("%(name)s's Profile", name=current_user.name),
page="me",

View File

@ -18,3 +18,4 @@ lxml>=3.8.0,<5.0.0
flask-wtf>=0.14.2,<1.2.0
chardet>=3.0.0,<4.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
chardet>=3.0.0,<4.1.0
advocate>=1.0.0,<1.1.0
Flask-Limiter>=2.3.0,<3.2.0
[options.extras_require]

File diff suppressed because it is too large Load Diff