276e7b29ca
- Fix container stalling during OAuth config save by making network requests non-blocking - Add explicit callback URI documentation with provider-specific examples - Enhance OAuth error messages with specific field names and actionable guidance - Add UI warning when switching from OAuth to standard auth about password requirements - Improve OAuth testing feedback with detailed endpoint validation - Fix translation compatibility issues in error messages - Standardize documentation placeholder domains - Add comprehensive troubleshooting guide for common OAuth issues Issues/feedback on new OAUTH setup Fixes #613
2384 lines
105 KiB
Python
2384 lines
105 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Calibre-Web Automated – fork of Calibre-Web
|
||
# Copyright (C) 2018-2025 Calibre-Web contributors
|
||
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
|
||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||
# See CONTRIBUTORS for full list of authors.
|
||
|
||
import os
|
||
import re
|
||
import json
|
||
import operator
|
||
import time
|
||
import sys
|
||
import string
|
||
import requests
|
||
from datetime import datetime, timedelta
|
||
from datetime import time as datetime_time
|
||
from functools import wraps
|
||
from urllib.parse import urlparse
|
||
|
||
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
|
||
from markupsafe import Markup
|
||
from .cw_login import current_user
|
||
from flask_babel import gettext as _
|
||
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
|
||
from sqlalchemy import and_
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
|
||
from sqlalchemy.sql.expression import func, or_, text
|
||
|
||
from . import constants, logger, helper, services, cli_param
|
||
from . import db, calibre_db, ub, web_server, config, updater_thread, gdriveutils, \
|
||
kobo_sync_status, schedule
|
||
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \
|
||
valid_email, check_username
|
||
from .embed_helper import get_calibre_binarypath
|
||
from .gdriveutils import is_gdrive_ready, gdrive_support
|
||
from .render_template import render_title_template, get_sidebar_config
|
||
from .services.worker import WorkerThread
|
||
from .usermanagement import user_login_required
|
||
from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language
|
||
from . import debug_info
|
||
from .string_helper import strip_whitespaces
|
||
|
||
log = logger.create()
|
||
|
||
feature_support = {
|
||
'ldap': bool(services.ldap),
|
||
'goodreads': bool(services.goodreads_support),
|
||
'kobo': bool(services.kobo),
|
||
'hardcover' : bool(services.hardcover),
|
||
'updater': constants.UPDATER_AVAILABLE,
|
||
'gmail': bool(services.gmail),
|
||
'scheduler': schedule.use_APScheduler,
|
||
'gdrive': gdrive_support
|
||
}
|
||
|
||
try:
|
||
import rarfile # pylint: disable=unused-import
|
||
|
||
feature_support['rar'] = True
|
||
except (ImportError, SyntaxError):
|
||
feature_support['rar'] = False
|
||
|
||
try:
|
||
from .oauth_bb import oauth_check, oauthblueprints
|
||
|
||
feature_support['oauth'] = True
|
||
except ImportError as err:
|
||
log.debug('Cannot import Flask-Dance, login with Oauth will not work: %s', err)
|
||
feature_support['oauth'] = False
|
||
oauthblueprints = []
|
||
oauth_check = {}
|
||
|
||
admi = Blueprint('admin', __name__)
|
||
|
||
|
||
def admin_required(f):
|
||
"""
|
||
Checks if current_user.role == 1
|
||
"""
|
||
|
||
@wraps(f)
|
||
def inner(*args, **kwargs):
|
||
if current_user.role_admin():
|
||
return f(*args, **kwargs)
|
||
abort(403)
|
||
|
||
return inner
|
||
|
||
|
||
@admi.before_app_request
|
||
def before_request():
|
||
# Safety net: if not configured but metadata.db now exists at default location, auto-set without redirect loop
|
||
if not config.db_configured:
|
||
try:
|
||
default_metadata = '/calibre-library/metadata.db'
|
||
if (not config.config_calibre_dir or not os.path.isfile(os.path.join(config.config_calibre_dir, 'metadata.db'))) \
|
||
and os.path.isfile(default_metadata):
|
||
config.config_calibre_dir = os.path.dirname(default_metadata)
|
||
log.info('[autoconfig] Late-detected calibre library at %s; updating config and rebuilding db session', config.config_calibre_dir)
|
||
try:
|
||
config.save()
|
||
except Exception as e:
|
||
log.error('Failed to save late autoconfig: %s', e)
|
||
# Re-run calibre db setup so subsequent handlers see a configured DB
|
||
from . import db as _db, cli_param as _cli_param
|
||
_db.CalibreDB.update_config(config)
|
||
_db.CalibreDB.setup_db(config.config_calibre_dir, _cli_param.settings_path)
|
||
except Exception as e:
|
||
log.error('Autoconfig safety net error: %s', e)
|
||
#try:
|
||
#if not ub.check_user_session(current_user.id,
|
||
# flask_session.get('_id')) and 'opds' not in request.path \
|
||
# and config.config_session == 1:
|
||
# logout_user()
|
||
#except AttributeError:
|
||
# pass # ? fails on requesting /ajax/emailstat during restart ?
|
||
g.constants = constants
|
||
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
|
||
g.allow_registration = config.config_public_reg
|
||
g.allow_anonymous = config.config_anonbrowse
|
||
g.allow_upload = config.config_uploading
|
||
# Use per-user theme if available; fallback to global config.config_theme for legacy/anonymous
|
||
try:
|
||
g.current_theme = getattr(current_user, 'theme', config.config_theme)
|
||
if current_user.is_anonymous and not hasattr(current_user, 'theme'):
|
||
g.current_theme = config.config_theme
|
||
except Exception:
|
||
g.current_theme = getattr(config, 'config_theme', 1)
|
||
g.config_authors_max = config.config_authors_max
|
||
if '/static/' not in request.path and not config.db_configured and \
|
||
request.endpoint not in ('admin.ajax_db_config',
|
||
'admin.simulatedbchange',
|
||
'admin.db_configuration',
|
||
'web.login',
|
||
'web.login_post',
|
||
'web.logout',
|
||
'admin.load_dialogtexts',
|
||
'admin.ajax_pathchooser'):
|
||
return redirect(url_for('admin.db_configuration'))
|
||
|
||
|
||
#@admi.route("/admin")
|
||
#@user_login_required
|
||
#def admin_forbidden():
|
||
# abort(403)
|
||
|
||
|
||
@admi.route("/shutdown", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def shutdown():
|
||
task = request.get_json().get('parameter', -1)
|
||
show_text = {}
|
||
if task in (0, 1): # valid commandos received
|
||
# close all database connections
|
||
calibre_db.dispose()
|
||
ub.dispose()
|
||
|
||
if task == 0:
|
||
show_text['text'] = _('Server restarted, please reload page.')
|
||
else:
|
||
show_text['text'] = _('Performing Server shutdown, please close window.')
|
||
# stop gevent/tornado server
|
||
web_server.stop(task == 0)
|
||
return json.dumps(show_text)
|
||
|
||
if task == 2:
|
||
log.warning("reconnecting to calibre database")
|
||
calibre_db.reconnect_db(config, ub.app_DB_path)
|
||
show_text['text'] = _('Success! Database Reconnected')
|
||
return json.dumps(show_text)
|
||
|
||
show_text['text'] = _('Unknown command')
|
||
return json.dumps(show_text), 400
|
||
|
||
|
||
@admi.route("/metadata_backup", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def queue_metadata_backup():
|
||
show_text = {}
|
||
log.warning("Queuing all books for metadata backup")
|
||
helper.set_all_metadata_dirty()
|
||
show_text['text'] = _('Success! Books queued for Metadata Backup, please check Tasks for result')
|
||
return json.dumps(show_text)
|
||
|
||
|
||
# method is available without login and not protected by CSRF to make it easy reachable, is per default switched off
|
||
# needed for docker applications, as changes on metadata.db from host are not visible to application
|
||
@admi.route("/reconnect", methods=['GET'])
|
||
def reconnect():
|
||
if cli_param.reconnect_enable:
|
||
calibre_db.reconnect_db(config, ub.app_DB_path)
|
||
return json.dumps({})
|
||
else:
|
||
log.debug("'/reconnect' was accessed but is not enabled")
|
||
abort(404)
|
||
|
||
|
||
@admi.route("/ajax/updateThumbnails", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def update_thumbnails():
|
||
content = config.get_scheduled_task_settings()
|
||
if content['schedule_generate_book_covers']:
|
||
log.info("Update of Cover cache requested")
|
||
helper.update_thumbnail_cache()
|
||
return ""
|
||
|
||
|
||
def cwa_get_package_versions() -> tuple[str, str, str, str]:
|
||
try:
|
||
with open("/app/CWA_RELEASE", "r") as f:
|
||
cwa_version = f.read()
|
||
except Exception:
|
||
cwa_version = "Unknown"
|
||
|
||
try:
|
||
with open("/app/KEPUBIFY_RELEASE", "r") as f:
|
||
kepubify_version = f.read()
|
||
except Exception:
|
||
kepubify_version = "Unknown"
|
||
|
||
try:
|
||
with open("/CALIBRE_RELEASE", "r") as f:
|
||
calibre_version = f.read()
|
||
except Exception:
|
||
calibre_version = "Unknown"
|
||
|
||
return cwa_version, kepubify_version, calibre_version
|
||
|
||
|
||
@admi.route("/admin/view")
|
||
@user_login_required
|
||
@admin_required
|
||
def admin():
|
||
version = updater_thread.get_current_version_info()
|
||
cwa_version, kepubify_version, calibre_version = cwa_get_package_versions()
|
||
if version is False:
|
||
commit = _('Unknown')
|
||
else:
|
||
if 'datetime' in version:
|
||
commit = version['datetime']
|
||
|
||
tz = timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone)
|
||
form_date = datetime.strptime(commit[:19], "%Y-%m-%dT%H:%M:%S")
|
||
if len(commit) > 19: # check if string has timezone
|
||
if commit[19] == '+':
|
||
form_date -= timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
|
||
elif commit[19] == '-':
|
||
form_date += timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
|
||
commit = format_datetime(form_date - tz, format='short')
|
||
else:
|
||
commit = version['version'].replace("b", " Beta")
|
||
|
||
all_user = ub.session.query(ub.User).all()
|
||
# 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, config=config, commit=commit,
|
||
cwa_version=cwa_version, kepubify_version=kepubify_version,
|
||
calibre_version=calibre_version, feature_support=feature_support,
|
||
schedule_time=schedule_time, schedule_duration=schedule_duration,
|
||
title=_("Admin page"), page="admin")
|
||
|
||
|
||
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def db_configuration():
|
||
if request.method == "POST":
|
||
return _db_configuration_update_helper()
|
||
return _db_configuration_result()
|
||
|
||
|
||
@admi.route("/admin/config", methods=["GET"])
|
||
@user_login_required
|
||
@admin_required
|
||
def configuration():
|
||
return render_title_template("config_edit.html",
|
||
config=config,
|
||
provider=oauthblueprints,
|
||
feature_support=feature_support,
|
||
title=_("Basic Configuration"), page="config")
|
||
|
||
|
||
@admi.route("/admin/ajaxconfig", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def ajax_config():
|
||
return _configuration_update_helper()
|
||
|
||
|
||
@admi.route("/admin/ajaxdbconfig", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def ajax_db_config():
|
||
return _db_configuration_update_helper()
|
||
|
||
|
||
@admi.route("/admin/alive", methods=["GET"])
|
||
@user_login_required
|
||
@admin_required
|
||
def calibreweb_alive():
|
||
return "", 200
|
||
|
||
|
||
@admi.route("/admin/viewconfig")
|
||
@user_login_required
|
||
@admin_required
|
||
def view_configuration():
|
||
read_column = calibre_db.session.query(db.CustomColumns) \
|
||
.filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all()
|
||
restrict_columns = calibre_db.session.query(db.CustomColumns) \
|
||
.filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all()
|
||
languages = calibre_db.speaking_language()
|
||
translations = get_available_locale()
|
||
return render_title_template("config_view_edit.html", conf=config, readColumns=read_column,
|
||
restrictColumns=restrict_columns,
|
||
languages=languages,
|
||
translations=translations,
|
||
title=_("UI Configuration"), page="uiconfig")
|
||
|
||
|
||
@admi.route("/admin/usertable")
|
||
@user_login_required
|
||
@admin_required
|
||
def edit_user_table():
|
||
visibility = current_user.view_settings.get('useredit', {})
|
||
languages = calibre_db.speaking_language()
|
||
translations = get_available_locale()
|
||
all_user = ub.session.query(ub.User)
|
||
tags = calibre_db.session.query(db.Tags) \
|
||
.join(db.books_tags_link) \
|
||
.join(db.Books) \
|
||
.filter(calibre_db.common_filters()) \
|
||
.group_by(text('books_tags_link.tag')) \
|
||
.order_by(db.Tags.name).all()
|
||
if config.config_restricted_column:
|
||
try:
|
||
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
|
||
except (KeyError, AttributeError, IndexError):
|
||
custom_values = []
|
||
log.error("Custom Column No.{} does not exist in calibre database".format(
|
||
config.config_restricted_column))
|
||
flash(_("Custom Column No.%(column)d does not exist in calibre database",
|
||
column=config.config_restricted_column),
|
||
category="error")
|
||
else:
|
||
custom_values = []
|
||
if not config.config_anonbrowse:
|
||
all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)
|
||
kobo_support = feature_support['kobo'] and config.config_kobo_sync
|
||
return render_title_template("user_table.html",
|
||
users=all_user.all(),
|
||
tags=tags,
|
||
custom_values=custom_values,
|
||
translations=translations,
|
||
languages=languages,
|
||
visiblility=visibility,
|
||
all_roles=constants.ALL_ROLES,
|
||
kobo_support=kobo_support,
|
||
sidebar_settings=constants.sidebar_settings,
|
||
title=_("Edit Users"),
|
||
page="usertable")
|
||
|
||
|
||
@admi.route("/ajax/listusers")
|
||
@user_login_required
|
||
@admin_required
|
||
def list_users():
|
||
off = int(request.args.get("offset") or 0)
|
||
limit = int(request.args.get("limit") or 10)
|
||
search = request.args.get("search")
|
||
sort = request.args.get("sort", "id")
|
||
state = None
|
||
if sort == "state":
|
||
state = json.loads(request.args.get("state", "[]"))
|
||
else:
|
||
if sort not in ub.User.__table__.columns.keys():
|
||
sort = "id"
|
||
order = request.args.get("order", "").lower()
|
||
|
||
if sort != "state" and order:
|
||
order = text(sort + " " + order)
|
||
elif not state:
|
||
order = ub.User.id.asc()
|
||
|
||
all_user = ub.session.query(ub.User)
|
||
if not config.config_anonbrowse:
|
||
all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)
|
||
|
||
total_count = filtered_count = all_user.count()
|
||
|
||
if search:
|
||
all_user = all_user.filter(or_(func.lower(ub.User.name).ilike("%" + search + "%"),
|
||
func.lower(ub.User.kindle_mail).ilike("%" + search + "%"),
|
||
func.lower(ub.User.email).ilike("%" + search + "%")))
|
||
if state:
|
||
users = calibre_db.get_checkbox_sorted(all_user.all(), state, off, limit, request.args.get("order", "").lower())
|
||
else:
|
||
users = all_user.order_by(order).offset(off).limit(limit).all()
|
||
if search:
|
||
filtered_count = len(users)
|
||
|
||
for user in users:
|
||
if user.default_language == "all":
|
||
user.default = _("All")
|
||
else:
|
||
user.default = get_user_locale_language(user.default_language)
|
||
|
||
table_entries = {'totalNotFiltered': total_count, 'total': filtered_count, "rows": users}
|
||
js_list = json.dumps(table_entries, cls=db.AlchemyEncoder)
|
||
response = make_response(js_list)
|
||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||
return response
|
||
|
||
|
||
@admi.route("/ajax/deleteuser", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def delete_user():
|
||
user_ids = request.form.to_dict(flat=False)
|
||
users = None
|
||
message = ""
|
||
if "userid[]" in user_ids:
|
||
users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids['userid[]'])).all()
|
||
elif "userid" in user_ids:
|
||
users = ub.session.query(ub.User).filter(ub.User.id == user_ids['userid'][0]).all()
|
||
count = 0
|
||
errors = list()
|
||
success = list()
|
||
if not users:
|
||
log.error("User not found")
|
||
return Response(json.dumps({'type': "danger", 'message': _("User not found")}), mimetype='application/json')
|
||
for user in users:
|
||
try:
|
||
message = _delete_user(user)
|
||
count += 1
|
||
except Exception as ex:
|
||
log.error(ex)
|
||
errors.append({'type': "danger", 'message': str(ex)})
|
||
|
||
if count == 1:
|
||
log.info("User {} deleted".format(user_ids))
|
||
success = [{'type': "success", 'message': message}]
|
||
elif count > 1:
|
||
log.info("Users {} deleted".format(user_ids))
|
||
success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}]
|
||
success.extend(errors)
|
||
return Response(json.dumps(success), mimetype='application/json')
|
||
|
||
|
||
@admi.route("/ajax/getlocale")
|
||
@user_login_required
|
||
@admin_required
|
||
def table_get_locale():
|
||
locale = get_available_locale()
|
||
ret = list()
|
||
current_locale = get_locale()
|
||
for loc in locale:
|
||
ret.append({'value': str(loc), 'text': loc.get_language_name(current_locale)})
|
||
return json.dumps(ret)
|
||
|
||
|
||
@admi.route("/ajax/getdefaultlanguage")
|
||
@user_login_required
|
||
@admin_required
|
||
def table_get_default_lang():
|
||
languages = calibre_db.speaking_language()
|
||
ret = list()
|
||
ret.append({'value': 'all', 'text': _('Show All')})
|
||
for lang in languages:
|
||
ret.append({'value': lang.lang_code, 'text': lang.name})
|
||
return json.dumps(ret)
|
||
|
||
|
||
@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def edit_list_user(param):
|
||
vals = request.form.to_dict(flat=False)
|
||
all_user = ub.session.query(ub.User)
|
||
if not config.config_anonbrowse:
|
||
all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)
|
||
# only one user is posted
|
||
if "pk" in vals:
|
||
users = [all_user.filter(ub.User.id == vals['pk'][0]).one_or_none()]
|
||
else:
|
||
if "pk[]" in vals:
|
||
users = all_user.filter(ub.User.id.in_(vals['pk[]'])).all()
|
||
else:
|
||
return _("Malformed request"), 400
|
||
if 'field_index' in vals:
|
||
vals['field_index'] = vals['field_index'][0]
|
||
if 'value' in vals:
|
||
vals['value'] = vals['value'][0]
|
||
elif not ('value[]' in vals):
|
||
return _("Malformed request"), 400
|
||
for user in users:
|
||
try:
|
||
if param in ['denied_tags', 'allowed_tags', 'allowed_column_value', 'denied_column_value']:
|
||
if 'value[]' in vals:
|
||
setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]']))
|
||
else:
|
||
setattr(user, param, strip_whitespaces(vals['value']))
|
||
else:
|
||
vals['value'] = strip_whitespaces(vals['value'])
|
||
if param == 'name':
|
||
if user.name == "Guest":
|
||
raise Exception(_("Guest Name can't be changed"))
|
||
user.name = check_username(vals['value'])
|
||
elif param == 'email':
|
||
user.email = check_email(vals['value'])
|
||
elif param == 'kobo_only_shelves_sync':
|
||
user.kobo_only_shelves_sync = int(vals['value'] == 'true')
|
||
elif param == 'kindle_mail':
|
||
user.kindle_mail = valid_email(vals['value']) if vals['value'] else ""
|
||
elif param.endswith('role'):
|
||
value = int(vals['field_index'])
|
||
if user.name == "Guest" and value in \
|
||
[constants.ROLE_ADMIN, constants.ROLE_PASSWD, constants.ROLE_EDIT_SHELFS]:
|
||
raise Exception(_("Guest can't have this role"))
|
||
# check for valid value, last on checks for power of 2 value
|
||
if value > 0 and value <= constants.ROLE_VIEWER and (value & value - 1 == 0 or value == 1):
|
||
if vals['value'] == 'true':
|
||
user.role |= value
|
||
elif vals['value'] == 'false':
|
||
if value == constants.ROLE_ADMIN:
|
||
if not ub.session.query(ub.User). \
|
||
filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
|
||
ub.User.id != user.id).count():
|
||
return Response(
|
||
json.dumps([{'type': "danger",
|
||
'message': _("No admin user remaining, can't remove admin role",
|
||
nick=user.name)}]), mimetype='application/json')
|
||
user.role &= ~value
|
||
else:
|
||
raise Exception(_("Value has to be true or false"))
|
||
else:
|
||
raise Exception(_("Invalid role"))
|
||
elif param.startswith('sidebar'):
|
||
value = int(vals['field_index'])
|
||
if user.name == "Guest" and value == constants.SIDEBAR_READ_AND_UNREAD:
|
||
raise Exception(_("Guest can't have this view"))
|
||
# check for valid value, last on checks for power of 2 value
|
||
if value > 0 and value <= constants.SIDEBAR_DUPLICATES and (value & value - 1 == 0 or value == 1):
|
||
if vals['value'] == 'true':
|
||
user.sidebar_view |= value
|
||
elif vals['value'] == 'false':
|
||
user.sidebar_view &= ~value
|
||
else:
|
||
raise Exception(_("Value has to be true or false"))
|
||
else:
|
||
raise Exception(_("Invalid view"))
|
||
elif param == 'locale':
|
||
if user.name == "Guest":
|
||
raise Exception(_("Guest's Locale is determined automatically and can't be set"))
|
||
if vals['value'] in get_available_translations():
|
||
user.locale = vals['value']
|
||
else:
|
||
raise Exception(_("No Valid Locale Given"))
|
||
elif param == 'default_language':
|
||
languages = calibre_db.session.query(db.Languages) \
|
||
.join(db.books_languages_link) \
|
||
.join(db.Books) \
|
||
.filter(calibre_db.common_filters()) \
|
||
.group_by(text('books_languages_link.lang_code')).all()
|
||
lang_codes = [lang.lang_code for lang in languages] + ["all"]
|
||
if vals['value'] in lang_codes:
|
||
user.default_language = vals['value']
|
||
else:
|
||
raise Exception(_("No Valid Book Language Given"))
|
||
else:
|
||
return _("Parameter not found"), 400
|
||
except Exception as ex:
|
||
log.error_or_exception(ex)
|
||
return str(ex), 400
|
||
ub.session_commit()
|
||
return ""
|
||
|
||
|
||
@admi.route("/ajax/user_table_settings", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def update_table_settings():
|
||
current_user.view_settings['useredit'] = json.loads(request.data)
|
||
try:
|
||
try:
|
||
flag_modified(current_user, "view_settings")
|
||
except AttributeError:
|
||
pass
|
||
ub.session.commit()
|
||
except (InvalidRequestError, OperationalError):
|
||
log.error("Invalid request received: {}".format(request))
|
||
return "Invalid request", 400
|
||
return ""
|
||
|
||
|
||
@admi.route("/admin/viewconfig", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def update_view_configuration():
|
||
to_save = request.form.to_dict()
|
||
|
||
_config_string(to_save, "config_calibre_web_title")
|
||
_config_string(to_save, "config_columns_to_ignore")
|
||
if _config_string(to_save, "config_title_regex"):
|
||
calibre_db.create_functions(config)
|
||
|
||
if not check_valid_read_column(to_save.get("config_read_column", "0")):
|
||
flash(_("Invalid Read Column"), category="error")
|
||
log.debug("Invalid Read column")
|
||
return view_configuration()
|
||
_config_int(to_save, "config_read_column")
|
||
|
||
if not check_valid_restricted_column(to_save.get("config_restricted_column", "0")):
|
||
flash(_("Invalid Restricted Column"), category="error")
|
||
log.debug("Invalid Restricted Column")
|
||
return view_configuration()
|
||
_config_int(to_save, "config_restricted_column")
|
||
|
||
_config_int(to_save, "config_theme")
|
||
_config_int(to_save, "config_random_books")
|
||
_config_int(to_save, "config_books_per_page")
|
||
_config_int(to_save, "config_authors_max")
|
||
_config_string(to_save, "config_default_language")
|
||
_config_string(to_save, "config_default_locale")
|
||
|
||
config.config_default_role = constants.selected_roles(to_save)
|
||
config.config_default_role &= ~constants.ROLE_ANONYMOUS
|
||
|
||
config.config_default_show = sum(int(k[5:]) for k in to_save if k.startswith('show_'))
|
||
if "Show_detail_random" in to_save:
|
||
config.config_default_show |= constants.DETAIL_RANDOM
|
||
|
||
config.save()
|
||
flash(_("Calibre-Web Automated configuration updated"), category="success")
|
||
log.debug("Calibre-Web Automated configuration updated")
|
||
before_request()
|
||
|
||
return view_configuration()
|
||
|
||
|
||
@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
|
||
@user_login_required
|
||
def load_dialogtexts(element_id):
|
||
texts = {"header": "", "main": "", "valid": 1}
|
||
if element_id == "config_delete_kobo_token":
|
||
texts["main"] = _('Do you really want to delete the Kobo Token?')
|
||
elif element_id == "btndeletedomain":
|
||
texts["main"] = _('Do you really want to delete this domain?')
|
||
elif element_id == "btndeluser":
|
||
texts["main"] = _('Do you really want to delete this user?')
|
||
elif element_id == "delete_shelf":
|
||
texts["main"] = _('Are you sure you want to delete this shelf?')
|
||
elif element_id == "select_locale":
|
||
texts["main"] = _('Are you sure you want to change locales of selected user(s)?')
|
||
elif element_id == "select_default_language":
|
||
texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?')
|
||
elif element_id == "role":
|
||
texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?')
|
||
elif element_id == "restrictions":
|
||
texts["main"] = _('Are you sure you want to change the selected restrictions for the selected user(s)?')
|
||
elif element_id == "sidebar_view":
|
||
texts["main"] = _('Are you sure you want to change the selected visibility restrictions '
|
||
'for the selected user(s)?')
|
||
elif element_id == "kobo_only_shelves_sync":
|
||
texts["main"] = _('Are you sure you want to change shelf sync behavior for the selected user(s)?')
|
||
elif element_id == "db_submit":
|
||
texts["main"] = _('Are you sure you want to change Calibre library location?')
|
||
elif element_id == "admin_refresh_cover_cache":
|
||
texts["main"] = _('Calibre-Web Automated will search for updated Covers '
|
||
'and update Cover Thumbnails, this may take a while?')
|
||
elif element_id == "btnfullsync":
|
||
texts["main"] = _("Are you sure you want delete Calibre-Web Automated's sync database "
|
||
"to force a full sync with your Kobo Reader?")
|
||
return json.dumps(texts)
|
||
|
||
|
||
@admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def edit_domain(allow):
|
||
# POST /post
|
||
# name: 'username', //name of field (column in db)
|
||
# pk: 1 //primary key (record id)
|
||
# value: 'superuser!' //new value
|
||
vals = request.form.to_dict()
|
||
answer = ub.session.query(ub.Registration).filter(ub.Registration.id == vals['pk']).first()
|
||
answer.domain = vals['value'].replace('*', '%').replace('?', '_').lower()
|
||
return ub.session_commit("Registering Domains edited {}".format(answer.domain))
|
||
|
||
|
||
@admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def add_domain(allow):
|
||
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
|
||
check = ub.session.query(ub.Registration).filter(ub.Registration.domain == domain_name) \
|
||
.filter(ub.Registration.allow == allow).first()
|
||
if not check:
|
||
new_domain = ub.Registration(domain=domain_name, allow=allow)
|
||
ub.session.add(new_domain)
|
||
ub.session_commit("Registering Domains added {}".format(domain_name))
|
||
return ""
|
||
|
||
|
||
@admi.route("/ajax/deletedomain", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def delete_domain():
|
||
try:
|
||
domain_id = request.form.to_dict()['domainid'].replace('*', '%').replace('?', '_').lower()
|
||
ub.session.query(ub.Registration).filter(ub.Registration.id == domain_id).delete()
|
||
ub.session_commit("Registering Domains deleted {}".format(domain_id))
|
||
# If last domain was deleted, add all domains by default
|
||
if not ub.session.query(ub.Registration).filter(ub.Registration.allow == 1).count():
|
||
new_domain = ub.Registration(domain="%.%", allow=1)
|
||
ub.session.add(new_domain)
|
||
ub.session_commit("Last Registering Domain deleted, added *.* as default")
|
||
except KeyError:
|
||
pass
|
||
return ""
|
||
|
||
|
||
@admi.route("/ajax/domainlist/<int:allow>")
|
||
@user_login_required
|
||
@admin_required
|
||
def list_domain(allow):
|
||
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
|
||
json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer])
|
||
js = json.dumps(json_dumps.replace('"', "'")).strip('"')
|
||
response = make_response(js.replace("'", '"'))
|
||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||
return response
|
||
|
||
|
||
@admi.route("/ajax/editrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
|
||
@admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def edit_restriction(res_type, user_id):
|
||
element = request.form.to_dict()
|
||
if element['id'].startswith('a'):
|
||
if res_type == 0: # Tags as template
|
||
elementlist = config.list_allowed_tags()
|
||
elementlist[int(element['id'][1:])] = element['Element']
|
||
config.config_allowed_tags = ','.join(elementlist)
|
||
config.save()
|
||
if res_type == 1: # CustomC
|
||
elementlist = config.list_allowed_column_values()
|
||
elementlist[int(element['id'][1:])] = element['Element']
|
||
config.config_allowed_column_value = ','.join(elementlist)
|
||
config.save()
|
||
if res_type == 2: # Tags per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
|
||
else:
|
||
usr = current_user
|
||
elementlist = usr.list_allowed_tags()
|
||
elementlist[int(element['id'][1:])] = element['Element']
|
||
usr.allowed_tags = ','.join(elementlist)
|
||
ub.session_commit("Changed allowed tags of user {} to {}".format(usr.name, usr.allowed_tags))
|
||
if res_type == 3: # CColumn per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
|
||
else:
|
||
usr = current_user
|
||
elementlist = usr.list_allowed_column_values()
|
||
elementlist[int(element['id'][1:])] = element['Element']
|
||
usr.allowed_column_value = ','.join(elementlist)
|
||
ub.session_commit("Changed allowed columns of user {} to {}".format(usr.name, usr.allowed_column_value))
|
||
if element['id'].startswith('d'):
|
||
if res_type == 0: # Tags as template
|
||
elementlist = config.list_denied_tags()
|
||
elementlist[int(element['id'][1:])] = element['Element']
|
||
config.config_denied_tags = ','.join(elementlist)
|
||
config.save()
|
||
if res_type == 1: # CustomC
|
||
elementlist = config.list_denied_column_values()
|
||
elementlist[int(element['id'][1:])] = element['Element']
|
||
config.config_denied_column_value = ','.join(elementlist)
|
||
config.save()
|
||
if res_type == 2: # Tags per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
|
||
else:
|
||
usr = current_user
|
||
elementlist = usr.list_denied_tags()
|
||
elementlist[int(element['id'][1:])] = element['Element']
|
||
usr.denied_tags = ','.join(elementlist)
|
||
ub.session_commit("Changed denied tags of user {} to {}".format(usr.name, usr.denied_tags))
|
||
if res_type == 3: # CColumn per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
|
||
else:
|
||
usr = current_user
|
||
elementlist = usr.list_denied_column_values()
|
||
elementlist[int(element['id'][1:])] = element['Element']
|
||
usr.denied_column_value = ','.join(elementlist)
|
||
ub.session_commit("Changed denied columns of user {} to {}".format(usr.name, usr.denied_column_value))
|
||
return ""
|
||
|
||
|
||
@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def add_user_0_restriction(res_type):
|
||
return add_restriction(res_type, 0)
|
||
|
||
|
||
@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def add_restriction(res_type, user_id):
|
||
element = request.form.to_dict()
|
||
if res_type == 0: # Tags as template
|
||
if 'submit_allow' in element:
|
||
config.config_allowed_tags = restriction_addition(element, config.list_allowed_tags)
|
||
config.save()
|
||
elif 'submit_deny' in element:
|
||
config.config_denied_tags = restriction_addition(element, config.list_denied_tags)
|
||
config.save()
|
||
if res_type == 1: # CCustom as template
|
||
if 'submit_allow' in element:
|
||
config.config_allowed_column_value = restriction_addition(element, config.list_allowed_column_values)
|
||
config.save()
|
||
elif 'submit_deny' in element:
|
||
config.config_denied_column_value = restriction_addition(element, config.list_denied_column_values)
|
||
config.save()
|
||
if res_type == 2: # Tags per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
|
||
else:
|
||
usr = current_user
|
||
if 'submit_allow' in element:
|
||
usr.allowed_tags = restriction_addition(element, usr.list_allowed_tags)
|
||
ub.session_commit("Changed allowed tags of user {} to {}".format(usr.name, usr.list_allowed_tags()))
|
||
elif 'submit_deny' in element:
|
||
usr.denied_tags = restriction_addition(element, usr.list_denied_tags)
|
||
ub.session_commit("Changed denied tags of user {} to {}".format(usr.name, usr.list_denied_tags()))
|
||
if res_type == 3: # CustomC per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
|
||
else:
|
||
usr = current_user
|
||
if 'submit_allow' in element:
|
||
usr.allowed_column_value = restriction_addition(element, usr.list_allowed_column_values)
|
||
ub.session_commit("Changed allowed columns of user {} to {}".format(usr.name, usr.list_allowed_column_values()))
|
||
elif 'submit_deny' in element:
|
||
usr.denied_column_value = restriction_addition(element, usr.list_denied_column_values)
|
||
ub.session_commit("Changed denied columns of user {} to {}".format(usr.name, usr.list_denied_column_values()))
|
||
return ""
|
||
|
||
|
||
@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def delete_user_0_restriction(res_type):
|
||
return delete_restriction(res_type, 0)
|
||
|
||
|
||
@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def delete_restriction(res_type, user_id):
|
||
element = request.form.to_dict()
|
||
if res_type == 0: # Tags as template
|
||
if element['id'].startswith('a'):
|
||
config.config_allowed_tags = restriction_deletion(element, config.list_allowed_tags)
|
||
config.save()
|
||
elif element['id'].startswith('d'):
|
||
config.config_denied_tags = restriction_deletion(element, config.list_denied_tags)
|
||
config.save()
|
||
elif res_type == 1: # CustomC as template
|
||
if element['id'].startswith('a'):
|
||
config.config_allowed_column_value = restriction_deletion(element, config.list_allowed_column_values)
|
||
config.save()
|
||
elif element['id'].startswith('d'):
|
||
config.config_denied_column_value = restriction_deletion(element, config.list_denied_column_values)
|
||
config.save()
|
||
elif res_type == 2: # Tags per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
|
||
else:
|
||
usr = current_user
|
||
if element['id'].startswith('a'):
|
||
usr.allowed_tags = restriction_deletion(element, usr.list_allowed_tags)
|
||
ub.session_commit("Deleted allowed tags of user {}: {}".format(usr.name, element['Element']))
|
||
elif element['id'].startswith('d'):
|
||
usr.denied_tags = restriction_deletion(element, usr.list_denied_tags)
|
||
ub.session_commit("Deleted denied tag of user {}: {}".format(usr.name, element['Element']))
|
||
elif res_type == 3: # Columns per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
|
||
else:
|
||
usr = current_user
|
||
if element['id'].startswith('a'):
|
||
usr.allowed_column_value = restriction_deletion(element, usr.list_allowed_column_values)
|
||
ub.session_commit("Deleted allowed columns of user {}: {}".format(usr.name, usr.list_allowed_column_values()))
|
||
|
||
elif element['id'].startswith('d'):
|
||
usr.denied_column_value = restriction_deletion(element, usr.list_denied_column_values)
|
||
ub.session_commit("Deleted denied columns of user {}: {}".format(usr.name, usr.list_denied_column_values()))
|
||
return ""
|
||
|
||
|
||
@admi.route("/ajax/listrestriction/<int:res_type>", defaults={"user_id": 0})
|
||
@admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>")
|
||
@user_login_required
|
||
@admin_required
|
||
def list_restriction(res_type, user_id):
|
||
if res_type == 0: # Tags as template
|
||
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
|
||
for i, x in enumerate(config.list_denied_tags()) if x != '']
|
||
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
|
||
for i, x in enumerate(config.list_allowed_tags()) if x != '']
|
||
json_dumps = restrict + allow
|
||
elif res_type == 1: # CustomC as template
|
||
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
|
||
for i, x in enumerate(config.list_denied_column_values()) if x != '']
|
||
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
|
||
for i, x in enumerate(config.list_allowed_column_values()) if x != '']
|
||
json_dumps = restrict + allow
|
||
elif res_type == 2: # Tags per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
|
||
else:
|
||
usr = current_user
|
||
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
|
||
for i, x in enumerate(usr.list_denied_tags()) if x != '']
|
||
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
|
||
for i, x in enumerate(usr.list_allowed_tags()) if x != '']
|
||
json_dumps = restrict + allow
|
||
elif res_type == 3: # CustomC per user
|
||
if isinstance(user_id, int):
|
||
usr = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
|
||
else:
|
||
usr = current_user
|
||
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
|
||
for i, x in enumerate(usr.list_denied_column_values()) if x != '']
|
||
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
|
||
for i, x in enumerate(usr.list_allowed_column_values()) if x != '']
|
||
json_dumps = restrict + allow
|
||
else:
|
||
json_dumps = ""
|
||
js = json.dumps(json_dumps)
|
||
response = make_response(js)
|
||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||
return response
|
||
|
||
|
||
@admi.route("/ajax/fullsync", methods=["POST"])
|
||
@user_login_required
|
||
def ajax_self_fullsync():
|
||
return do_full_kobo_sync(current_user.id)
|
||
|
||
|
||
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def ajax_fullsync(userid):
|
||
return do_full_kobo_sync(userid)
|
||
|
||
|
||
@admi.route("/ajax/pathchooser/")
|
||
@user_login_required
|
||
@admin_required
|
||
def ajax_pathchooser():
|
||
return pathchooser()
|
||
|
||
|
||
def do_full_kobo_sync(userid):
|
||
count = ub.session.query(ub.KoboSyncedBooks).filter(userid == ub.KoboSyncedBooks.user_id).delete()
|
||
message = _("{} sync entries deleted").format(count)
|
||
ub.session_commit(message)
|
||
return Response(json.dumps([{"type": "success", "message": message}]), mimetype='application/json')
|
||
|
||
|
||
def check_valid_read_column(column):
|
||
if column != "0":
|
||
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
||
.filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all():
|
||
return False
|
||
return True
|
||
|
||
|
||
def check_valid_restricted_column(column):
|
||
if column != "0":
|
||
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
||
.filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all():
|
||
return False
|
||
return True
|
||
|
||
|
||
def restriction_addition(element, list_func):
|
||
elementlist = list_func()
|
||
if elementlist == ['']:
|
||
elementlist = []
|
||
if not element['add_element'] in elementlist:
|
||
elementlist += [element['add_element']]
|
||
return ','.join(elementlist)
|
||
|
||
|
||
def restriction_deletion(element, list_func):
|
||
elementlist = list_func()
|
||
if element['Element'] in elementlist:
|
||
elementlist.remove(element['Element'])
|
||
return ','.join(elementlist)
|
||
|
||
|
||
def prepare_tags(user, action, tags_name, id_list):
|
||
if "tags" in tags_name:
|
||
tags = calibre_db.session.query(db.Tags).filter(db.Tags.id.in_(id_list)).all()
|
||
if not tags:
|
||
raise Exception(_("Tag not found"))
|
||
new_tags_list = [x.name for x in tags]
|
||
else:
|
||
try:
|
||
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
|
||
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
|
||
except (KeyError, AttributeError, IndexError):
|
||
log.error("Custom Column No.{} does not exist in calibre database".format(
|
||
config.config_restricted_column))
|
||
raise Exception(_("Custom Column No.%(column)d does not exist in calibre database",
|
||
column=config.config_restricted_column))
|
||
new_tags_list = [x.value for x in tags]
|
||
saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
|
||
if action == "remove":
|
||
saved_tags_list = [x for x in saved_tags_list if x not in new_tags_list]
|
||
elif action == "add":
|
||
saved_tags_list.extend(x for x in new_tags_list if x not in saved_tags_list)
|
||
else:
|
||
raise Exception(_("Invalid Action"))
|
||
return ",".join(saved_tags_list)
|
||
|
||
|
||
def get_drives(current):
|
||
drive_letters = []
|
||
for d in string.ascii_uppercase:
|
||
if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower():
|
||
drive = "{}:\\".format(d)
|
||
data = {"name": drive, "fullpath": drive, "type": "dir", "size": "", "sort": "_" + drive.lower()}
|
||
drive_letters.append(data)
|
||
return drive_letters
|
||
|
||
|
||
def pathchooser():
|
||
browse_for = "folder"
|
||
folder_only = request.args.get('folder', False) == "true"
|
||
file_filter = request.args.get('filter', "")
|
||
path = os.path.normpath(request.args.get('path', ""))
|
||
|
||
if os.path.isfile(path):
|
||
old_file = path
|
||
path = os.path.dirname(path)
|
||
else:
|
||
old_file = ""
|
||
|
||
absolute = False
|
||
|
||
if os.path.isdir(path):
|
||
cwd = os.path.realpath(path)
|
||
absolute = True
|
||
else:
|
||
cwd = os.getcwd()
|
||
|
||
cwd = os.path.normpath(os.path.realpath(cwd))
|
||
parent_dir = os.path.dirname(cwd)
|
||
if not absolute:
|
||
if os.path.realpath(cwd) == os.path.realpath("/"):
|
||
cwd = os.path.relpath(cwd)
|
||
else:
|
||
cwd = os.path.relpath(cwd) + os.path.sep
|
||
parent_dir = os.path.relpath(parent_dir) + os.path.sep
|
||
|
||
files = []
|
||
if os.path.realpath(cwd) == os.path.realpath("/") \
|
||
or (sys.platform == "win32" and os.path.realpath(cwd)[1:] == os.path.realpath("/")[1:]):
|
||
# we are in root
|
||
parent_dir = ""
|
||
if sys.platform == "win32":
|
||
files = get_drives(cwd)
|
||
|
||
try:
|
||
folders = os.listdir(cwd)
|
||
except Exception:
|
||
folders = []
|
||
|
||
for f in folders:
|
||
try:
|
||
sanitized_f = str(Markup.escape(f))
|
||
data = {"name": sanitized_f, "fullpath": os.path.join(cwd, sanitized_f)}
|
||
data["sort"] = data["fullpath"].lower()
|
||
except Exception:
|
||
continue
|
||
|
||
if os.path.isfile(os.path.join(cwd, f)):
|
||
if folder_only:
|
||
continue
|
||
if file_filter != "" and file_filter != f:
|
||
continue
|
||
data["type"] = "file"
|
||
data["size"] = os.path.getsize(os.path.join(cwd, f))
|
||
|
||
power = 0
|
||
while (data["size"] >> 10) > 0.3:
|
||
power += 1
|
||
data["size"] >>= 10
|
||
units = ("", "K", "M", "G", "T")
|
||
data["size"] = str(data["size"]) + " " + units[power] + "Byte"
|
||
else:
|
||
data["type"] = "dir"
|
||
data["size"] = ""
|
||
|
||
files.append(data)
|
||
|
||
files = sorted(files, key=operator.itemgetter("type", "sort"))
|
||
|
||
context = {
|
||
"cwd": cwd,
|
||
"files": files,
|
||
"parentdir": parent_dir,
|
||
"type": browse_for,
|
||
"oldfile": old_file,
|
||
"absolute": absolute,
|
||
}
|
||
return json.dumps(context)
|
||
|
||
|
||
def _config_int(to_save, x, func=int):
|
||
return config.set_from_dictionary(to_save, x, func)
|
||
|
||
|
||
def _config_checkbox(to_save, x):
|
||
return config.set_from_dictionary(to_save, x, lambda y: y == "on", False)
|
||
|
||
|
||
def _config_checkbox_int(to_save, x):
|
||
return config.set_from_dictionary(to_save, x, lambda y: 1 if (y == "on") else 0, 0)
|
||
|
||
|
||
def _config_string(to_save, x):
|
||
return config.set_from_dictionary(to_save, x, lambda y: strip_whitespaces(y) if y else y)
|
||
|
||
|
||
def _configuration_gdrive_helper(to_save):
|
||
gdrive_error = None
|
||
if to_save.get("config_use_google_drive"):
|
||
gdrive_secrets = {}
|
||
|
||
if not os.path.isfile(gdriveutils.SETTINGS_YAML):
|
||
config.config_use_google_drive = False
|
||
|
||
if gdrive_support:
|
||
gdrive_error = gdriveutils.get_error_text(gdrive_secrets)
|
||
if "config_use_google_drive" in to_save and not config.config_use_google_drive and not gdrive_error:
|
||
with open(gdriveutils.CLIENT_SECRETS, 'r') as settings:
|
||
gdrive_secrets = json.load(settings)['web']
|
||
if not gdrive_secrets:
|
||
return _configuration_result(_('client_secrets.json Is Not Configured For Web Application'))
|
||
gdriveutils.update_settings(
|
||
gdrive_secrets['client_id'],
|
||
gdrive_secrets['client_secret'],
|
||
gdrive_secrets['redirect_uris'][0]
|
||
)
|
||
|
||
# always show Google Drive settings, but in case of error deny support
|
||
new_gdrive_value = (not gdrive_error) and ("config_use_google_drive" in to_save)
|
||
if config.config_use_google_drive and not new_gdrive_value:
|
||
config.config_google_drive_watch_changes_response = {}
|
||
config.config_use_google_drive = new_gdrive_value
|
||
if _config_string(to_save, "config_google_drive_folder"):
|
||
gdriveutils.deleteDatabaseOnChange()
|
||
return gdrive_error
|
||
|
||
|
||
def _configuration_oauth_helper(to_save):
|
||
reboot_required = False
|
||
|
||
for element in oauthblueprints:
|
||
update = {}
|
||
if element["provider_name"] == "generic":
|
||
if to_save["config_generic_oauth_client_id"] != element["oauth_client_id"]:
|
||
reboot_required = True
|
||
update["oauth_client_id"] = to_save["config_generic_oauth_client_id"]
|
||
if to_save["config_generic_oauth_client_secret"] != element["oauth_client_secret"]:
|
||
reboot_required = True
|
||
update["oauth_client_secret"] = to_save["config_generic_oauth_client_secret"]
|
||
|
||
# Handle metadata URL (takes precedence over manual configuration)
|
||
metadata_url = to_save.get("config_generic_oauth_metadata_url", "")
|
||
if metadata_url != element.get("metadata_url", ""):
|
||
reboot_required = True
|
||
update["metadata_url"] = metadata_url
|
||
|
||
# If metadata URL is provided, try to fetch endpoints
|
||
if metadata_url:
|
||
try:
|
||
resp = requests.get(metadata_url, timeout=3, verify=constants.OAUTH_SSL_STRICT)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
update["oauth_base_url"] = data.get("issuer", "")
|
||
update["oauth_authorize_url"] = data.get("authorization_endpoint", "")
|
||
update["oauth_token_url"] = data.get("token_endpoint", "")
|
||
update["oauth_userinfo_url"] = data.get("userinfo_endpoint", "")
|
||
else:
|
||
log.warning(f"Failed to fetch OAuth metadata: HTTP {resp.status_code}")
|
||
except requests.exceptions.Timeout:
|
||
log.warning("OAuth metadata fetch timed out - configuration saved but endpoints not auto-discovered")
|
||
except requests.exceptions.RequestException as ex:
|
||
log.warning(f"Failed to fetch OAuth metadata: {ex}")
|
||
except Exception as ex:
|
||
log.error(f"Unexpected error fetching OAuth metadata: {ex}")
|
||
|
||
# Handle manual server URL (fallback or override)
|
||
elif to_save["config_generic_oauth_server_url"] != element["oauth_base_url"]:
|
||
reboot_required = True
|
||
update["oauth_base_url"] = to_save["config_generic_oauth_server_url"]
|
||
try:
|
||
resp = requests.get(
|
||
os.path.join(update["oauth_base_url"], ".well-known/openid-configuration"),
|
||
timeout=3,
|
||
verify=constants.OAUTH_SSL_STRICT
|
||
)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
update["oauth_authorize_url"] = data.get("authorization_endpoint", "")
|
||
update["oauth_token_url"] = data.get("token_endpoint", "")
|
||
update["oauth_userinfo_url"] = data.get("userinfo_endpoint", "")
|
||
else:
|
||
log.warning(f"Failed to fetch OIDC configuration: HTTP {resp.status_code}")
|
||
except requests.exceptions.Timeout:
|
||
log.warning("OIDC configuration fetch timed out - configuration saved but endpoints not auto-discovered")
|
||
except requests.exceptions.RequestException as ex:
|
||
log.warning(f"Failed to fetch OIDC configuration: {ex}")
|
||
except Exception as ex:
|
||
log.error(f"Unexpected error fetching OIDC configuration: {ex}")
|
||
|
||
# Handle manual endpoint URLs if metadata URL is not used
|
||
if not metadata_url:
|
||
# Map form field names to database field names
|
||
endpoint_mappings = {
|
||
"config_generic_oauth_auth_url": "oauth_authorize_url",
|
||
"config_generic_oauth_token_url": "oauth_token_url",
|
||
"config_generic_oauth_userinfo_url": "oauth_userinfo_url"
|
||
}
|
||
|
||
for form_field, db_field in endpoint_mappings.items():
|
||
if form_field in to_save and to_save[form_field] != element.get(db_field, ""):
|
||
reboot_required = True
|
||
update[db_field] = to_save[form_field]
|
||
|
||
# Handle scope
|
||
if to_save.get("config_generic_oauth_scope", "") != element.get("scope", ""):
|
||
reboot_required = True
|
||
update["scope"] = to_save.get("config_generic_oauth_scope", "")
|
||
|
||
# Handle username mapper
|
||
if to_save.get("config_generic_oauth_username_mapper", "") != element.get("username_mapper", ""):
|
||
reboot_required = True
|
||
update["username_mapper"] = to_save.get("config_generic_oauth_username_mapper", "")
|
||
|
||
# Handle email mapper
|
||
if to_save.get("config_generic_oauth_email_mapper", "") != element.get("email_mapper", ""):
|
||
reboot_required = True
|
||
update["email_mapper"] = to_save.get("config_generic_oauth_email_mapper", "")
|
||
|
||
# Handle login button text
|
||
if to_save.get("config_generic_oauth_login_button", "") != element.get("login_button", ""):
|
||
reboot_required = True
|
||
update["login_button"] = to_save.get("config_generic_oauth_login_button", "")
|
||
|
||
if to_save["config_generic_oauth_admin_group"] != element["oauth_admin_group"]:
|
||
reboot_required = True
|
||
update["oauth_admin_group"] = to_save["config_generic_oauth_admin_group"]
|
||
else:
|
||
if to_save["config_" + str(element['id']) + "_oauth_client_id"] != element["oauth_client_id"]:
|
||
reboot_required = True
|
||
update["oauth_client_id"] = to_save["config_" + str(element['id']) + "_oauth_client_id"]
|
||
if to_save["config_" + str(element['id']) + "_oauth_client_secret"] != element["oauth_client_secret"]:
|
||
reboot_required = True
|
||
update["oauth_client_secret"] = to_save["config_" + str(element['id']) + "_oauth_client_secret"]
|
||
|
||
oauth_client_id = update.get("oauth_client_id", element["oauth_client_id"])
|
||
oauth_client_secret = update.get("oauth_client_secret", element["oauth_client_secret"])
|
||
update["active"] = 1 if oauth_client_id and oauth_client_secret else 0
|
||
|
||
ub.session.query(ub.OAuthProvider).filter(ub.OAuthProvider.id == element['id']).update(update)
|
||
|
||
return reboot_required, None
|
||
|
||
|
||
def _configuration_logfile_helper(to_save):
|
||
reboot_required = False
|
||
reboot_required |= _config_int(to_save, "config_log_level")
|
||
reboot_required |= _config_string(to_save, "config_logfile")
|
||
if not logger.is_valid_logfile(config.config_logfile):
|
||
return reboot_required, \
|
||
_configuration_result(_('Logfile Location is not Valid, Please Enter Correct Path'))
|
||
|
||
reboot_required |= _config_checkbox_int(to_save, "config_access_log")
|
||
reboot_required |= _config_string(to_save, "config_access_logfile")
|
||
if not logger.is_valid_logfile(config.config_access_logfile):
|
||
return reboot_required, \
|
||
_configuration_result(_('Access Logfile Location is not Valid, Please Enter Correct Path'))
|
||
return reboot_required, None
|
||
|
||
|
||
def _configuration_ldap_helper(to_save):
|
||
reboot_required = False
|
||
reboot_required |= _config_int(to_save, "config_ldap_port")
|
||
reboot_required |= _config_int(to_save, "config_ldap_authentication")
|
||
reboot_required |= _config_string(to_save, "config_ldap_dn")
|
||
reboot_required |= _config_string(to_save, "config_ldap_serv_username")
|
||
reboot_required |= _config_string(to_save, "config_ldap_user_object")
|
||
reboot_required |= _config_string(to_save, "config_ldap_group_object_filter")
|
||
reboot_required |= _config_string(to_save, "config_ldap_group_members_field")
|
||
reboot_required |= _config_string(to_save, "config_ldap_member_user_object")
|
||
reboot_required |= _config_checkbox(to_save, "config_ldap_openldap")
|
||
reboot_required |= _config_int(to_save, "config_ldap_encryption")
|
||
reboot_required |= _config_string(to_save, "config_ldap_cacert_path")
|
||
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")
|
||
|
||
address = urlparse(to_save.get("config_ldap_provider_url", ""))
|
||
to_save["config_ldap_provider_url"] = (address.hostname or address.path).strip("/")
|
||
reboot_required |= _config_string(to_save, "config_ldap_provider_url")
|
||
|
||
if to_save.get("config_ldap_serv_password_e", "") != "":
|
||
reboot_required |= 1
|
||
config.set_from_dictionary(to_save, "config_ldap_serv_password_e")
|
||
config.save()
|
||
|
||
if not config.config_ldap_provider_url \
|
||
or not config.config_ldap_port \
|
||
or not config.config_ldap_dn \
|
||
or not config.config_ldap_user_object:
|
||
return reboot_required, _configuration_result(_('Please Enter a LDAP Provider, '
|
||
'Port, DN and User Object Identifier'))
|
||
|
||
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_e):
|
||
return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account and Password'))
|
||
else:
|
||
if not config.config_ldap_serv_username:
|
||
return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account'))
|
||
|
||
if config.config_ldap_group_object_filter:
|
||
if config.config_ldap_group_object_filter.count("%s") != 1:
|
||
return reboot_required, \
|
||
_configuration_result(_('LDAP Group Object Filter Needs to Have One "%s" Format Identifier'))
|
||
if config.config_ldap_group_object_filter.count("(") != config.config_ldap_group_object_filter.count(")"):
|
||
return reboot_required, _configuration_result(_('LDAP Group Object Filter Has Unmatched Parenthesis'))
|
||
|
||
if config.config_ldap_user_object.count("%s") != 1:
|
||
return reboot_required, \
|
||
_configuration_result(_('LDAP User Object Filter needs to Have One "%s" Format Identifier'))
|
||
if config.config_ldap_user_object.count("(") != config.config_ldap_user_object.count(")"):
|
||
return reboot_required, _configuration_result(_('LDAP User Object Filter Has Unmatched Parenthesis'))
|
||
|
||
if to_save.get("ldap_import_user_filter") == '0':
|
||
config.config_ldap_member_user_object = ""
|
||
else:
|
||
if config.config_ldap_member_user_object.count("%s") != 1:
|
||
return reboot_required, \
|
||
_configuration_result(_('LDAP Member User Filter needs to Have One "%s" Format Identifier'))
|
||
if config.config_ldap_member_user_object.count("(") != config.config_ldap_member_user_object.count(")"):
|
||
return reboot_required, _configuration_result(_('LDAP Member User Filter Has Unmatched Parenthesis'))
|
||
|
||
if config.config_ldap_cacert_path or config.config_ldap_cert_path or config.config_ldap_key_path:
|
||
if not (os.path.isfile(config.config_ldap_cacert_path) and
|
||
os.path.isfile(config.config_ldap_cert_path) and
|
||
os.path.isfile(config.config_ldap_key_path)):
|
||
return reboot_required, \
|
||
_configuration_result(_('LDAP CACertificate, Certificate or Key Location is not Valid, '
|
||
'Please Enter Correct Path'))
|
||
return reboot_required, None
|
||
|
||
|
||
@admi.route("/ajax/simulatedbchange", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def simulatedbchange():
|
||
db_change, db_valid = _db_simulate_change()
|
||
return Response(json.dumps({"change": db_change, "valid": db_valid}), mimetype='application/json')
|
||
|
||
|
||
@admi.route("/admin/user/new", methods=["GET", "POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def new_user():
|
||
content = ub.User()
|
||
languages = calibre_db.speaking_language()
|
||
translations = get_available_locale()
|
||
kobo_support = feature_support['kobo'] and config.config_kobo_sync
|
||
if request.method == "POST":
|
||
to_save = request.form.to_dict()
|
||
_handle_new_user(to_save, content, languages, translations, kobo_support)
|
||
else:
|
||
content.role = config.config_default_role
|
||
content.sidebar_view = config.config_default_show
|
||
content.locale = config.config_default_locale
|
||
content.default_language = config.config_default_language
|
||
return render_title_template("user_edit.html", new_user=1, content=content,
|
||
config=config, translations=translations,
|
||
languages=languages, title=_("Add New User"), page="newuser",
|
||
kobo_support=kobo_support, registered_oauth=oauth_check)
|
||
|
||
|
||
@admi.route("/admin/mailsettings", methods=["GET"])
|
||
@user_login_required
|
||
@admin_required
|
||
def edit_mailsettings():
|
||
content = config.get_mail_settings()
|
||
return render_title_template("email_edit.html", content=content, title=_("Edit Email Server Settings"),
|
||
page="mailset", feature_support=feature_support)
|
||
|
||
|
||
@admi.route("/admin/mailsettings", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def update_mailsettings():
|
||
to_save = request.form.to_dict()
|
||
_config_int(to_save, "mail_server_type")
|
||
if to_save.get("invalidate"):
|
||
config.mail_gmail_token = {}
|
||
try:
|
||
flag_modified(config, "mail_gmail_token")
|
||
except AttributeError:
|
||
pass
|
||
elif to_save.get("gmail"):
|
||
try:
|
||
config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token)
|
||
flash(_("Success! Gmail Account Verified."), category="success")
|
||
except Exception as ex:
|
||
flash(str(ex), category="error")
|
||
log.error(ex)
|
||
return edit_mailsettings()
|
||
|
||
else:
|
||
_config_int(to_save, "mail_port")
|
||
_config_int(to_save, "mail_use_ssl")
|
||
if to_save.get("mail_password_e", ""):
|
||
_config_string(to_save, "mail_password_e")
|
||
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
|
||
config.mail_server = strip_whitespaces(to_save.get('mail_server', ""))
|
||
config.mail_from = strip_whitespaces(to_save.get('mail_from', ""))
|
||
config.mail_login = strip_whitespaces(to_save.get('mail_login', ""))
|
||
try:
|
||
config.save()
|
||
except (OperationalError, InvalidRequestError) as e:
|
||
ub.session.rollback()
|
||
log.error_or_exception("Settings Database error: {}".format(e))
|
||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||
return edit_mailsettings()
|
||
except Exception as e:
|
||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||
return edit_mailsettings()
|
||
|
||
if to_save.get("test"):
|
||
if current_user.email:
|
||
result = send_test_mail(current_user.email, current_user.name)
|
||
if result is None:
|
||
flash(_("Test e-mail queued for sending to %(email)s, please check Tasks for result",
|
||
email=current_user.email), category="info")
|
||
else:
|
||
flash(_("There was an error sending the Test e-mail: %(res)s", res=result), category="error")
|
||
else:
|
||
flash(_("Please configure your e-mail address first..."), category="error")
|
||
else:
|
||
flash(_("Email Server Settings updated"), category="success")
|
||
|
||
return edit_mailsettings()
|
||
|
||
|
||
@admi.route("/admin/scheduledtasks")
|
||
@user_login_required
|
||
@admin_required
|
||
def edit_scheduledtasks():
|
||
content = config.get_scheduled_task_settings()
|
||
time_field = list()
|
||
duration_field = list()
|
||
|
||
for n in range(24):
|
||
time_field.append((n, format_time(datetime_time(hour=n), format="short", )))
|
||
for n in range(5, 65, 5):
|
||
t = timedelta(hours=n // 60, minutes=n % 60)
|
||
duration_field.append((n, format_timedelta(t, threshold=.97)))
|
||
|
||
return render_title_template("schedule_edit.html",
|
||
config=content,
|
||
starttime=time_field,
|
||
duration=duration_field,
|
||
title=_("Edit Scheduled Tasks Settings"))
|
||
|
||
|
||
@admi.route("/admin/scheduledtasks", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
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")
|
||
else:
|
||
flash(_("Invalid start time for task specified"), category="error")
|
||
error = True
|
||
if 0 < int(to_save.get("schedule_duration")) <= 60:
|
||
_config_int(to_save, "schedule_duration")
|
||
else:
|
||
flash(_("Invalid duration for task specified"), category="error")
|
||
error = True
|
||
_config_checkbox(to_save, "schedule_generate_book_covers")
|
||
_config_checkbox(to_save, "schedule_generate_series_covers")
|
||
_config_checkbox(to_save, "schedule_metadata_backup")
|
||
_config_checkbox(to_save, "schedule_reconnect")
|
||
|
||
if not error:
|
||
try:
|
||
config.save()
|
||
flash(_("Scheduled tasks settings updated"), category="success")
|
||
|
||
# Cancel any running tasks
|
||
schedule.end_scheduled_tasks()
|
||
|
||
# Re-register tasks with new settings
|
||
schedule.register_scheduled_tasks(config.schedule_reconnect)
|
||
except IntegrityError:
|
||
ub.session.rollback()
|
||
log.error("An unknown error occurred while saving scheduled tasks settings")
|
||
flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
|
||
except OperationalError:
|
||
ub.session.rollback()
|
||
log.error("Settings DB is not Writeable")
|
||
flash(_("Settings DB is not Writeable"), category="error")
|
||
|
||
return edit_scheduledtasks()
|
||
|
||
|
||
@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def edit_user(user_id):
|
||
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
|
||
if not content or (not config.config_anonbrowse and content.name == "Guest"):
|
||
flash(_("User not found"), category="error")
|
||
return redirect(url_for('admin.admin'))
|
||
languages = calibre_db.speaking_language(return_all_languages=True)
|
||
translations = get_available_locale()
|
||
kobo_support = feature_support['kobo'] and config.config_kobo_sync
|
||
if request.method == "POST":
|
||
to_save = request.form.to_dict()
|
||
resp = _handle_edit_user(to_save, content, languages, translations, kobo_support)
|
||
if resp:
|
||
return resp
|
||
return render_title_template("user_edit.html",
|
||
translations=translations,
|
||
languages=languages,
|
||
new_user=0,
|
||
content=content,
|
||
config=config,
|
||
registered_oauth=oauth_check,
|
||
mail_configured=config.get_mail_server_configured(),
|
||
kobo_support=kobo_support,
|
||
title=_("Edit User %(nick)s", nick=content.name),
|
||
page="edituser")
|
||
|
||
|
||
@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def reset_user_password(user_id):
|
||
if current_user is not None and current_user.is_authenticated:
|
||
ret, message = reset_password(user_id)
|
||
if ret == 1:
|
||
log.debug("Password for user %s reset", message)
|
||
flash(_("Success! Password for user %(user)s reset", user=message), category="success")
|
||
elif ret == 0:
|
||
log.error("An unknown error occurred. Please try again later.")
|
||
flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
|
||
else:
|
||
log.error("Please configure the SMTP mail settings.")
|
||
flash(_("Oops! Please configure the SMTP mail settings."), category="error")
|
||
return redirect(url_for('admin.admin'))
|
||
|
||
|
||
@admi.route("/admin/logfile")
|
||
@user_login_required
|
||
@admin_required
|
||
def view_logfile():
|
||
logfiles = {0: logger.get_logfile(config.config_logfile),
|
||
1: logger.get_accesslogfile(config.config_access_logfile)}
|
||
return render_title_template("logviewer.html",
|
||
title=_("Logfile viewer"),
|
||
accesslog_enable=config.config_access_log,
|
||
log_enable=bool(config.config_logfile != logger.LOG_TO_STDOUT),
|
||
logfiles=logfiles,
|
||
page="logfile")
|
||
|
||
|
||
@admi.route("/ajax/log/<int:logtype>")
|
||
@user_login_required
|
||
@admin_required
|
||
def send_logfile(logtype):
|
||
if logtype == 1:
|
||
logfile = logger.get_accesslogfile(config.config_access_logfile)
|
||
return send_from_directory(os.path.dirname(logfile),
|
||
os.path.basename(logfile))
|
||
if logtype == 0:
|
||
logfile = logger.get_logfile(config.config_logfile)
|
||
return send_from_directory(os.path.dirname(logfile),
|
||
os.path.basename(logfile))
|
||
else:
|
||
return ""
|
||
|
||
|
||
@admi.route("/admin/logdownload/<int:logtype>")
|
||
@user_login_required
|
||
@admin_required
|
||
def download_log(logtype):
|
||
if logtype == 0:
|
||
file_name = logger.get_logfile(config.config_logfile)
|
||
elif logtype == 1:
|
||
file_name = logger.get_accesslogfile(config.config_access_logfile)
|
||
else:
|
||
abort(404)
|
||
if logger.is_valid_logfile(file_name):
|
||
return debug_info.assemble_logfiles(file_name)
|
||
abort(404)
|
||
|
||
|
||
@admi.route("/admin/debug")
|
||
@user_login_required
|
||
@admin_required
|
||
def download_debug():
|
||
return debug_info.send_debug()
|
||
|
||
|
||
@admi.route("/get_update_status", methods=['GET'])
|
||
@user_login_required
|
||
@admin_required
|
||
def get_update_status():
|
||
if feature_support['updater']:
|
||
log.info("Update status requested")
|
||
return updater_thread.get_available_updates(request.method)
|
||
else:
|
||
return ''
|
||
|
||
|
||
@admi.route("/get_updater_status", methods=['GET', 'POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def get_updater_status():
|
||
status = {}
|
||
if feature_support['updater']:
|
||
if request.method == "POST":
|
||
commit = request.form.to_dict()
|
||
if "start" in commit and commit['start'] == 'True':
|
||
txt = {
|
||
"1": _(u'Requesting update package'),
|
||
"2": _(u'Downloading update package'),
|
||
"3": _(u'Unzipping update package'),
|
||
"4": _(u'Replacing files'),
|
||
"5": _(u'Database connections are closed'),
|
||
"6": _(u'Stopping server'),
|
||
"7": _(u'Update finished, please press okay and reload page'),
|
||
"8": _(u'Update failed:') + u' ' + _(u'HTTP Error'),
|
||
"9": _(u'Update failed:') + u' ' + _(u'Connection error'),
|
||
"10": _(u'Update failed:') + u' ' + _(u'Timeout while establishing connection'),
|
||
"11": _(u'Update failed:') + u' ' + _(u'General error'),
|
||
"12": _(u'Update failed:') + u' ' + _(u'Update file could not be saved in temp dir'),
|
||
"13": _(u'Update failed:') + u' ' + _(u'Files could not be replaced during update')
|
||
}
|
||
status['text'] = txt
|
||
updater_thread.status = 0
|
||
updater_thread.resume()
|
||
status['status'] = updater_thread.get_update_status()
|
||
elif request.method == "GET":
|
||
try:
|
||
status['status'] = updater_thread.get_update_status()
|
||
if status['status'] == -1:
|
||
status['status'] = 7
|
||
except Exception:
|
||
status['status'] = 11
|
||
return json.dumps(status)
|
||
return ''
|
||
|
||
|
||
def ldap_import_create_user(user, user_data):
|
||
user_login_field = extract_dynamic_field_from_filter(user, config.config_ldap_user_object)
|
||
|
||
try:
|
||
username = user_data[user_login_field][0].decode('utf-8')
|
||
except KeyError as ex:
|
||
log.error("Failed to extract LDAP user: %s - %s", user, ex)
|
||
message = _(u'Failed to extract at least One LDAP User')
|
||
return 0, message
|
||
|
||
# check for duplicate username
|
||
if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first():
|
||
# if ub.session.query(ub.User).filter(ub.User.name == username).first():
|
||
log.warning("LDAP User %s Already in Database", user_data)
|
||
return 0, None
|
||
|
||
ereader_mail = ''
|
||
if 'mail' in user_data:
|
||
useremail = user_data['mail'][0].decode('utf-8')
|
||
if len(user_data['mail']) > 1:
|
||
ereader_mail = user_data['mail'][1].decode('utf-8')
|
||
|
||
else:
|
||
log.debug('No Mail Field Found in LDAP Response')
|
||
useremail = username + '@email.com'
|
||
|
||
try:
|
||
# check for duplicate email
|
||
useremail = check_email(useremail)
|
||
except Exception as ex:
|
||
log.warning("LDAP Email Error: {}, {}".format(user_data, ex))
|
||
return 0, None
|
||
content = ub.User()
|
||
content.name = username
|
||
content.password = '' # dummy password which will be replaced by ldap one
|
||
content.email = useremail
|
||
content.kindle_mail = ereader_mail
|
||
content.default_language = config.config_default_language
|
||
content.locale = config.config_default_locale
|
||
content.role = config.config_default_role
|
||
content.sidebar_view = config.config_default_show
|
||
content.allowed_tags = config.config_allowed_tags
|
||
content.denied_tags = config.config_denied_tags
|
||
content.allowed_column_value = config.config_allowed_column_value
|
||
content.denied_column_value = config.config_denied_column_value
|
||
ub.session.add(content)
|
||
try:
|
||
ub.session.commit()
|
||
return 1, None # increase no of users
|
||
except Exception as ex:
|
||
log.warning("Failed to create LDAP user: %s - %s", user, ex)
|
||
ub.session.rollback()
|
||
message = _(u'Failed to Create at Least One LDAP User')
|
||
return 0, message
|
||
|
||
|
||
@admi.route('/import_ldap_users', methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def import_ldap_users():
|
||
showtext = {}
|
||
try:
|
||
new_users = services.ldap.get_group_members(config.config_ldap_group_name)
|
||
except (services.ldap.LDAPException, TypeError, AttributeError, KeyError) as e:
|
||
log.error_or_exception(e)
|
||
showtext['text'] = _(u'Error: %(ldaperror)s', ldaperror=e)
|
||
return json.dumps(showtext)
|
||
if not new_users:
|
||
log.debug('LDAP empty response')
|
||
showtext['text'] = _(u'Error: No user returned in response of LDAP server')
|
||
return json.dumps(showtext)
|
||
|
||
imported = 0
|
||
for username in new_users:
|
||
if isinstance(username, bytes):
|
||
user = username.decode('utf-8')
|
||
else:
|
||
user = username
|
||
if '=' in user:
|
||
# if member object field is empty take user object as filter
|
||
if config.config_ldap_member_user_object:
|
||
query_filter = config.config_ldap_member_user_object
|
||
else:
|
||
query_filter = config.config_ldap_user_object
|
||
try:
|
||
user_identifier = extract_user_identifier(user, query_filter)
|
||
except Exception as ex:
|
||
log.warning(ex)
|
||
continue
|
||
else:
|
||
user_identifier = user
|
||
query_filter = None
|
||
try:
|
||
user_data = services.ldap.get_object_details(user=user_identifier, query_filter=query_filter)
|
||
except AttributeError as ex:
|
||
log.error_or_exception(ex)
|
||
continue
|
||
if user_data:
|
||
user_count, message = ldap_import_create_user(user, user_data)
|
||
if message:
|
||
showtext['text'] = message
|
||
else:
|
||
imported += user_count
|
||
else:
|
||
log.warning("LDAP User: %s Not Found", user)
|
||
showtext['text'] = _(u'At Least One LDAP User Not Found in Database')
|
||
if not showtext:
|
||
showtext['text'] = _(u'{} User Successfully Imported'.format(imported))
|
||
return json.dumps(showtext)
|
||
|
||
|
||
@admi.route("/ajax/canceltask", methods=['POST'])
|
||
@user_login_required
|
||
@admin_required
|
||
def cancel_task():
|
||
task_id = request.get_json().get('task_id', None)
|
||
worker = WorkerThread.get_instance()
|
||
worker.end_task(task_id)
|
||
return ""
|
||
|
||
|
||
def _db_simulate_change():
|
||
param = request.form.to_dict()
|
||
to_save = dict()
|
||
incoming = param.get('config_calibre_dir', config.config_calibre_dir or '')
|
||
incoming = strip_whitespaces(re.sub(r'[\\/]metadata\.db$', '', incoming, flags=re.IGNORECASE))
|
||
# Fallback: if nothing provided and default metadata exists, assume /calibre-library
|
||
if not incoming and os.path.isfile('/calibre-library/metadata.db'):
|
||
incoming = '/calibre-library'
|
||
to_save['config_calibre_dir'] = incoming
|
||
db_valid, db_change = calibre_db.check_valid_db(to_save["config_calibre_dir"],
|
||
ub.app_DB_path,
|
||
config.config_calibre_uuid)
|
||
db_change = bool(db_change and config.config_calibre_dir)
|
||
return db_change, db_valid
|
||
|
||
|
||
def _db_configuration_update_helper():
|
||
db_change = False
|
||
to_save = request.form.to_dict()
|
||
gdrive_error = None
|
||
|
||
incoming = to_save.get('config_calibre_dir', config.config_calibre_dir or '')
|
||
incoming = re.sub(r'[\\/]metadata\.db$', '', incoming, flags=re.IGNORECASE)
|
||
if not incoming and os.path.isfile('/calibre-library/metadata.db'):
|
||
incoming = '/calibre-library'
|
||
to_save['config_calibre_dir'] = incoming
|
||
db_valid = False
|
||
try:
|
||
db_change, db_valid = _db_simulate_change()
|
||
|
||
# gdrive_error drive setup
|
||
gdrive_error = _configuration_gdrive_helper(to_save)
|
||
except (OperationalError, InvalidRequestError) as e:
|
||
ub.session.rollback()
|
||
log.error_or_exception("Settings Database error: {}".format(e))
|
||
_db_configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig), gdrive_error)
|
||
try:
|
||
metadata_db = os.path.join(to_save['config_calibre_dir'], "metadata.db")
|
||
if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db):
|
||
gdriveutils.downloadFile(None, "metadata.db", metadata_db)
|
||
db_change = True
|
||
except Exception as ex:
|
||
return _db_configuration_result('{}'.format(ex), gdrive_error)
|
||
config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
|
||
if config.config_calibre_split:
|
||
split_dir = to_save.get("config_calibre_split_dir")
|
||
if not os.path.exists(split_dir):
|
||
return _db_configuration_result(_("Books path not valid"), gdrive_error)
|
||
else:
|
||
_config_string(to_save, "config_calibre_split_dir")
|
||
|
||
if db_change or not db_valid or not config.db_configured \
|
||
or config.config_calibre_dir != to_save["config_calibre_dir"]:
|
||
if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']:
|
||
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
|
||
else:
|
||
calibre_db.setup_db(to_save['config_calibre_dir'], ub.app_DB_path)
|
||
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||
# if db changed -> delete shelfs, delete download books, delete read books, kobo sync...
|
||
if db_change:
|
||
log.info("Calibre Database changed, all Calibre-Web Automated info related to old Database gets deleted")
|
||
ub.session.query(ub.Downloads).delete()
|
||
ub.session.query(ub.ArchivedBook).delete()
|
||
ub.session.query(ub.ReadBook).delete()
|
||
ub.session.query(ub.BookShelf).delete()
|
||
ub.session.query(ub.Bookmark).delete()
|
||
ub.session.query(ub.KoboReadingState).delete()
|
||
ub.session.query(ub.KoboStatistics).delete()
|
||
ub.session.query(ub.KoboSyncedBooks).delete()
|
||
helper.delete_thumbnail_cache()
|
||
ub.session_commit()
|
||
# deleted visibilities based on custom column and tags
|
||
config.config_restricted_column = 0
|
||
config.config_denied_tags = ""
|
||
config.config_allowed_tags = ""
|
||
config.config_columns_to_ignore = ""
|
||
config.config_denied_column_value = ""
|
||
config.config_allowed_column_value = ""
|
||
config.config_read_column = 0
|
||
_config_string(to_save, "config_calibre_dir")
|
||
calibre_db.update_config(config)
|
||
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
|
||
flash(_("DB is not Writeable"), category="warning")
|
||
calibre_db.update_config(config)
|
||
config.save()
|
||
return _db_configuration_result(None, gdrive_error)
|
||
|
||
|
||
def _configuration_update_helper():
|
||
reboot_required = False
|
||
to_save = request.form.to_dict()
|
||
try:
|
||
reboot_required |= _config_string(to_save, "config_trustedhosts")
|
||
reboot_required |= _config_string(to_save, "config_keyfile")
|
||
if config.config_keyfile and not os.path.isfile(config.config_keyfile):
|
||
return _configuration_result(_('Keyfile Location is not Valid, Please Enter Correct Path'))
|
||
|
||
reboot_required |= _config_string(to_save, "config_certfile")
|
||
if config.config_certfile and not os.path.isfile(config.config_certfile):
|
||
return _configuration_result(_('Certfile Location is not Valid, Please Enter Correct Path'))
|
||
|
||
_config_checkbox_int(to_save, "config_uploading")
|
||
_config_checkbox_int(to_save, "config_unicode_filename")
|
||
_config_checkbox_int(to_save, "config_embed_metadata")
|
||
# Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case
|
||
reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse")
|
||
and config.config_login_type == constants.LOGIN_LDAP)
|
||
_config_checkbox_int(to_save, "config_public_reg")
|
||
_config_checkbox_int(to_save, "config_register_email")
|
||
reboot_required |= _config_checkbox_int(to_save, "config_kobo_sync")
|
||
_config_int(to_save, "config_external_port")
|
||
_config_checkbox_int(to_save, "config_kobo_proxy")
|
||
_config_checkbox_int(to_save, "config_hardcover_sync")
|
||
|
||
if "config_upload_formats" in to_save:
|
||
to_save["config_upload_formats"] = ','.join(
|
||
helper.uniq([x.strip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
||
_config_string(to_save, "config_upload_formats")
|
||
|
||
_config_string(to_save, "config_calibre")
|
||
_config_string(to_save, "config_binariesdir")
|
||
_config_string(to_save, "config_kepubifypath")
|
||
if "config_binariesdir" in to_save:
|
||
calibre_status = helper.check_calibre(config.config_binariesdir)
|
||
if calibre_status:
|
||
return _configuration_result(calibre_status)
|
||
to_save["config_converterpath"] = get_calibre_binarypath("ebook-convert")
|
||
_config_string(to_save, "config_converterpath")
|
||
|
||
reboot_required |= _config_int(to_save, "config_login_type")
|
||
|
||
# LDAP configurator
|
||
if config.config_login_type == constants.LOGIN_LDAP:
|
||
reboot, message = _configuration_ldap_helper(to_save)
|
||
if message:
|
||
return message
|
||
reboot_required |= reboot
|
||
|
||
# Remote login configuration
|
||
_config_checkbox(to_save, "config_remote_login")
|
||
if not config.config_remote_login:
|
||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.token_type == 0).delete()
|
||
|
||
# Goodreads configuration
|
||
_config_checkbox(to_save, "config_use_goodreads")
|
||
_config_string(to_save, "config_goodreads_api_key")
|
||
if services.goodreads_support:
|
||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||
config.config_use_goodreads)
|
||
|
||
# Hardcover configuration
|
||
_config_checkbox(to_save, "config_hardcover_sync")
|
||
_config_string(to_save, "config_hardcover_token")
|
||
|
||
_config_int(to_save, "config_updatechannel")
|
||
|
||
# Reverse proxy login configuration
|
||
_config_checkbox(to_save, "config_allow_reverse_proxy_header_login")
|
||
_config_string(to_save, "config_reverse_proxy_login_header_name")
|
||
|
||
# OAuth configuration
|
||
if config.config_login_type == constants.LOGIN_OAUTH:
|
||
reboot, message = _configuration_oauth_helper(to_save)
|
||
if message:
|
||
return message
|
||
reboot_required |= reboot
|
||
|
||
# logfile configuration
|
||
reboot, message = _configuration_logfile_helper(to_save)
|
||
if message:
|
||
return message
|
||
reboot_required |= reboot
|
||
|
||
# security configuration
|
||
_config_checkbox(to_save, "config_check_extensions")
|
||
_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_character")
|
||
_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")
|
||
reboot_required |= _config_string(to_save, "config_limiter_uri")
|
||
reboot_required |= _config_string(to_save, "config_limiter_options")
|
||
|
||
# Rarfile Content configuration
|
||
_config_string(to_save, "config_rarfile_location")
|
||
if "config_rarfile_location" in to_save:
|
||
unrar_status = helper.check_unrar(config.config_rarfile_location)
|
||
if unrar_status:
|
||
return _configuration_result(unrar_status)
|
||
except (OperationalError, InvalidRequestError) as e:
|
||
ub.session.rollback()
|
||
log.error_or_exception("Settings Database error: {}".format(e))
|
||
_configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig))
|
||
|
||
config.save()
|
||
if reboot_required:
|
||
web_server.stop(True)
|
||
|
||
return _configuration_result(None, reboot_required)
|
||
|
||
|
||
def _configuration_result(error_flash=None, reboot=False):
|
||
resp = {}
|
||
if error_flash:
|
||
log.error(error_flash)
|
||
config.load()
|
||
resp['result'] = [{'type': "danger", 'message': error_flash}]
|
||
else:
|
||
resp['result'] = [{'type': "success", 'message': _("Calibre-Web Automated configuration updated")}]
|
||
resp['reboot'] = reboot
|
||
resp['config_upload'] = config.config_upload_formats
|
||
return Response(json.dumps(resp), mimetype='application/json')
|
||
|
||
|
||
def _db_configuration_result(error_flash=None, gdrive_error=None):
|
||
gdrive_authenticate = not is_gdrive_ready()
|
||
gdrivefolders = []
|
||
if not gdrive_error and config.config_use_google_drive:
|
||
gdrive_error = gdriveutils.get_error_text()
|
||
if gdrive_error and gdrive_support:
|
||
log.error(gdrive_error)
|
||
gdrive_error = _(gdrive_error)
|
||
flash(gdrive_error, category="error")
|
||
else:
|
||
if not gdrive_authenticate and gdrive_support:
|
||
gdrivefolders = gdriveutils.listRootFolders()
|
||
if error_flash:
|
||
log.error(error_flash)
|
||
config.load()
|
||
flash(error_flash, category="error")
|
||
elif request.method == "POST" and not gdrive_error:
|
||
flash(_("Database Settings updated"), category="success")
|
||
|
||
return render_title_template("config_db.html",
|
||
config=config,
|
||
show_authenticate_google_drive=gdrive_authenticate,
|
||
gdriveError=gdrive_error,
|
||
gdrivefolders=gdrivefolders,
|
||
feature_support=feature_support,
|
||
title=_("Database Configuration"), page="dbconfig")
|
||
|
||
|
||
def _handle_new_user(to_save, content, languages, translations, kobo_support):
|
||
content.default_language = to_save["default_language"]
|
||
content.locale = to_save.get("locale", content.locale)
|
||
|
||
content.sidebar_view = sum(int(key[5:]) for key in to_save if key.startswith('show_'))
|
||
if "show_detail_random" in to_save:
|
||
content.sidebar_view |= constants.DETAIL_RANDOM
|
||
|
||
content.role = constants.selected_roles(to_save)
|
||
# Set default theme (caliBlur = 1) for new users
|
||
try:
|
||
# Use global default theme config (acts as default for new users)
|
||
content.theme = getattr(config, 'config_theme', 1)
|
||
except Exception:
|
||
pass
|
||
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"])
|
||
if to_save.get("kindle_mail"):
|
||
content.kindle_mail = valid_email(to_save["kindle_mail"])
|
||
if config.config_public_reg and not check_valid_domain(content.email):
|
||
log.info("E-mail: {} for new user is not from valid domain".format(content.email))
|
||
raise Exception(_("E-mail is not from valid domain"))
|
||
except Exception as ex:
|
||
flash(str(ex), category="error")
|
||
return render_title_template("user_edit.html", new_user=1, content=content,
|
||
config=config,
|
||
translations=translations,
|
||
languages=languages, title=_("Add new user"), page="newuser",
|
||
kobo_support=kobo_support, registered_oauth=oauth_check)
|
||
try:
|
||
content.allowed_tags = config.config_allowed_tags
|
||
content.denied_tags = config.config_denied_tags
|
||
content.allowed_column_value = config.config_allowed_column_value
|
||
content.denied_column_value = config.config_denied_column_value
|
||
# No default value for kobo sync shelf setting
|
||
content.kobo_only_shelves_sync = to_save.get("kobo_only_shelves_sync", 0) == "on"
|
||
ub.session.add(content)
|
||
ub.session.commit()
|
||
flash(_("User '%(user)s' created", user=content.name), category="success")
|
||
log.debug("User {} created".format(content.name))
|
||
return redirect(url_for('admin.admin'))
|
||
except IntegrityError:
|
||
ub.session.rollback()
|
||
log.error("Found an existing account for {} or {}".format(content.name, content.email))
|
||
flash(_("Oops! An account already exists for this Email. or name."), category="error")
|
||
except OperationalError as e:
|
||
ub.session.rollback()
|
||
log.error_or_exception("Settings Database error: {}".format(e))
|
||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||
|
||
|
||
def _delete_user(content):
|
||
if ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
|
||
ub.User.id != content.id).count():
|
||
if content.name != "Guest":
|
||
# Delete all books in shelfs belonging to user, all shelfs of user, downloadstat of user, read status
|
||
# and user itself
|
||
ub.session.query(ub.ReadBook).filter(content.id == ub.ReadBook.user_id).delete()
|
||
ub.session.query(ub.Downloads).filter(content.id == ub.Downloads.user_id).delete()
|
||
for us in ub.session.query(ub.Shelf).filter(content.id == ub.Shelf.user_id):
|
||
ub.session.query(ub.BookShelf).filter(us.id == ub.BookShelf.shelf).delete()
|
||
ub.session.query(ub.Shelf).filter(content.id == ub.Shelf.user_id).delete()
|
||
ub.session.query(ub.Bookmark).filter(content.id == ub.Bookmark.user_id).delete()
|
||
ub.session.query(ub.User).filter(ub.User.id == content.id).delete()
|
||
ub.session.query(ub.ArchivedBook).filter(ub.ArchivedBook.user_id == content.id).delete()
|
||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == content.id).delete()
|
||
ub.session.query(ub.User_Sessions).filter(ub.User_Sessions.user_id == content.id).delete()
|
||
ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.user_id == content.id).delete()
|
||
# delete KoboReadingState and all it's children
|
||
kobo_entries = ub.session.query(ub.KoboReadingState).filter(ub.KoboReadingState.user_id == content.id).all()
|
||
for kobo_entry in kobo_entries:
|
||
ub.session.delete(kobo_entry)
|
||
ub.session_commit()
|
||
log.info("User {} deleted".format(content.name))
|
||
return _("User '%(nick)s' deleted", nick=content.name)
|
||
else:
|
||
# log.warning(_("Can't delete Guest User"))
|
||
raise Exception(_("Can't delete Guest User"))
|
||
else:
|
||
# log.warning("No admin user remaining, can't delete user")
|
||
raise Exception(_("No admin user remaining, can't delete user"))
|
||
|
||
|
||
def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
||
if to_save.get("delete"):
|
||
try:
|
||
flash(_delete_user(content), category="success")
|
||
except Exception as ex:
|
||
log.error(ex)
|
||
flash(str(ex), category="error")
|
||
return redirect(url_for('admin.admin'))
|
||
# Theme update for admin editing user
|
||
if 'theme' in to_save:
|
||
try:
|
||
theme_val = int(to_save.get('theme'))
|
||
if theme_val in (0,1):
|
||
content.theme = theme_val
|
||
except Exception:
|
||
pass
|
||
# Proceed with remaining updates (previously skipped when 'theme' in to_save)
|
||
if not ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
|
||
ub.User.id != content.id).count() and 'admin_role' not in to_save:
|
||
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'))
|
||
|
||
val = [int(k[5:]) for k in to_save if k.startswith('show_')]
|
||
sidebar, __ = get_sidebar_config()
|
||
for element in sidebar:
|
||
value = element['visibility']
|
||
if value in val and not content.check_visibility(value):
|
||
content.sidebar_view |= value
|
||
elif value not in val and content.check_visibility(value):
|
||
content.sidebar_view &= ~value
|
||
|
||
if to_save.get("Show_detail_random"):
|
||
content.sidebar_view |= constants.DETAIL_RANDOM
|
||
else:
|
||
content.sidebar_view &= ~constants.DETAIL_RANDOM
|
||
|
||
old_state = content.kobo_only_shelves_sync
|
||
content.kobo_only_shelves_sync = int(to_save.get("kobo_only_shelves_sync") == "on") or 0
|
||
# 1 -> 0: nothing has to be done
|
||
# 0 -> 1: all synced books have to be added to archived books, + currently synced shelfs
|
||
# which don't have to be synced have to be removed (added to Shelf archive)
|
||
if old_state == 0 and content.kobo_only_shelves_sync == 1:
|
||
kobo_sync_status.update_on_sync_shelfs(content.id)
|
||
# Auto-send and metadata fetch settings
|
||
content.auto_send_enabled = to_save.get("auto_send_enabled") == "on"
|
||
content.auto_metadata_fetch = to_save.get("auto_metadata_fetch") == "on"
|
||
if to_save.get("default_language"):
|
||
content.default_language = to_save["default_language"]
|
||
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"))
|
||
if new_email != content.email:
|
||
content.email = check_email(new_email)
|
||
# Query username, if not existing, change
|
||
if to_save.get("name", content.name) != content.name:
|
||
if to_save.get("name") == "Guest":
|
||
raise Exception(_("Guest Name can't be changed"))
|
||
content.name = check_username(to_save["name"])
|
||
if to_save.get("kindle_mail") != content.kindle_mail:
|
||
content.kindle_mail = valid_email(to_save["kindle_mail"]) if to_save["kindle_mail"] else ""
|
||
except Exception as ex:
|
||
log.error(ex)
|
||
flash(str(ex), category="error")
|
||
return render_title_template("user_edit.html",
|
||
translations=translations,
|
||
languages=languages,
|
||
mail_configured=config.get_mail_server_configured(),
|
||
kobo_support=kobo_support,
|
||
new_user=0,
|
||
content=content,
|
||
config=config,
|
||
registered_oauth=oauth_check,
|
||
title=_("Edit User %(nick)s", nick=content.name),
|
||
page="edituser")
|
||
try:
|
||
ub.session_commit()
|
||
flash(_("User '%(nick)s' updated", nick=content.name), category="success")
|
||
except IntegrityError as ex:
|
||
ub.session.rollback()
|
||
log.error("An unknown error occurred while changing user: {}".format(str(ex)))
|
||
flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
|
||
except OperationalError as e:
|
||
ub.session.rollback()
|
||
log.error_or_exception("Settings Database error: {}".format(e))
|
||
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||
return ""
|
||
|
||
|
||
def extract_user_data_from_field(user, field):
|
||
match = re.search(field + r"=(.*?)($|(?<!\\),)", user, re.IGNORECASE | re.UNICODE)
|
||
if match:
|
||
return match.group(1)
|
||
else:
|
||
raise Exception("Could Not Parse LDAP User: {}".format(user))
|
||
|
||
|
||
def extract_dynamic_field_from_filter(user, filtr):
|
||
match = re.search(r"([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
|
||
if match:
|
||
return match.group(1)
|
||
else:
|
||
raise Exception("Could Not Parse LDAP Userfield: {}", user)
|
||
|
||
|
||
def extract_user_identifier(user, filtr):
|
||
dynamic_field = extract_dynamic_field_from_filter(user, filtr)
|
||
return extract_user_data_from_field(user, dynamic_field)
|
||
|
||
|
||
@admi.route("/admin/test_oidc", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def test_oidc():
|
||
url = request.get_json().get('url')
|
||
if not url:
|
||
return json.dumps({'success': False, 'message': 'URL is required.'}), 400
|
||
|
||
if not url.startswith('http'):
|
||
url = 'https://' + url
|
||
|
||
discovery_url = url.rstrip('/') + '/.well-known/openid-configuration'
|
||
|
||
try:
|
||
response = requests.get(discovery_url, timeout=5, verify=constants.OAUTH_SSL_STRICT)
|
||
response.raise_for_status()
|
||
# Try to parse the JSON and extract useful information
|
||
oidc_config = response.json()
|
||
|
||
# Extract key endpoints for validation
|
||
endpoints = []
|
||
if 'authorization_endpoint' in oidc_config:
|
||
endpoints.append('authorization')
|
||
if 'token_endpoint' in oidc_config:
|
||
endpoints.append('token')
|
||
if 'userinfo_endpoint' in oidc_config:
|
||
endpoints.append('userinfo')
|
||
|
||
endpoint_info = " Found endpoints: " + ', '.join(endpoints) + "." if endpoints else ""
|
||
|
||
return json.dumps({
|
||
'success': True,
|
||
'message': _('Connection successful! OIDC discovery endpoint is accessible.%(endpoints)s',
|
||
endpoints=endpoint_info)
|
||
})
|
||
except requests.exceptions.HTTPError as e:
|
||
if e.response.status_code == 404:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: OIDC discovery endpoint not found (404). Check if the base URL is correct.')}), 200
|
||
else:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: Server returned status code %(code)s', code=e.response.status_code)}), 200
|
||
except requests.exceptions.ConnectionError:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: Could not connect to server. Check the URL and network connectivity.')}), 200
|
||
except requests.exceptions.Timeout:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: Request timed out. The server may be slow or unreachable.')}), 200
|
||
except ValueError:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: Server returned invalid JSON. This may not be an OIDC endpoint.')}), 200
|
||
except Exception as e:
|
||
log.error("OIDC test connection failed: %s", e)
|
||
return json.dumps({'success': False, 'message': _('Connection failed: %(error)s', error=str(e))}), 200
|
||
|
||
|
||
@admi.route("/admin/test_metadata", methods=["POST"])
|
||
@user_login_required
|
||
@admin_required
|
||
def test_metadata():
|
||
metadata_url = request.get_json().get('url')
|
||
if not metadata_url:
|
||
return json.dumps({'success': False, 'message': 'Metadata URL is required.'}), 400
|
||
|
||
if not metadata_url.startswith('http'):
|
||
metadata_url = 'https://' + metadata_url
|
||
|
||
try:
|
||
response = requests.get(metadata_url, timeout=5, verify=constants.OAUTH_SSL_STRICT)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
# Validate that it contains required OIDC fields
|
||
required_fields = ['issuer', 'authorization_endpoint', 'token_endpoint']
|
||
missing_fields = [field for field in required_fields if not data.get(field)]
|
||
|
||
if missing_fields:
|
||
return json.dumps({
|
||
'success': False,
|
||
'message': _('Metadata is missing required OIDC fields: %(fields)s. This may not be a valid OIDC metadata endpoint.',
|
||
fields=', '.join(missing_fields))
|
||
}), 200
|
||
|
||
# Count available OAuth endpoints for user feedback
|
||
oauth_endpoints = ['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint',
|
||
'end_session_endpoint', 'introspection_endpoint', 'revocation_endpoint']
|
||
found_endpoints = [ep for ep in oauth_endpoints if ep in data]
|
||
endpoint_count = len(found_endpoints)
|
||
has_userinfo = 'userinfo_endpoint' in data
|
||
|
||
message = _('Metadata URL is valid! Found %(count)s OAuth endpoints.', count=endpoint_count)
|
||
if has_userinfo:
|
||
message += _(' User info endpoint is available.')
|
||
else:
|
||
message += _(' Note: User info endpoint not found - this may cause authentication issues.')
|
||
|
||
return json.dumps({'success': True, 'message': message})
|
||
except requests.exceptions.HTTPError as e:
|
||
if e.response.status_code == 404:
|
||
return json.dumps({'success': False, 'message': _('Metadata URL not found (404). Please check the URL is correct.')}), 200
|
||
else:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: Server returned status code %(code)s', code=e.response.status_code)}), 200
|
||
except requests.exceptions.ConnectionError:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: Could not connect to metadata URL. Check the URL and network connectivity.')}), 200
|
||
except requests.exceptions.Timeout:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: Request timed out.')}), 200
|
||
except ValueError:
|
||
return json.dumps({'success': False, 'message': _('Connection failed: Invalid JSON in response.')}), 200
|
||
except Exception as e:
|
||
log.error("Metadata test failed: %s", e)
|
||
return json.dumps({'success': False, 'message': _('An unknown error occurred.')}), 200
|