mirror of
https://github.com/bunkerity/bunkerweb
synced 2026-05-24 09:28:37 +00:00
chore: Refactor UI readonly fallback logic + Optimize the web UI
This commit is contained in:
parent
b2203b0656
commit
304d63c1bd
3 changed files with 48 additions and 34 deletions
|
|
@ -68,6 +68,7 @@ def set_sqlite_pragma(dbapi_connection, _):
|
|||
|
||||
class Database:
|
||||
DB_STRING_RX = re_compile(r"^(?P<database>(mariadb|mysql)(\+pymysql)?|sqlite(\+pysqlite)?|postgresql(\+psycopg)?):/+(?P<path>/[^\s]+)")
|
||||
READONLY_ERROR = ("readonly", "read-only", "command denied", "Access denied")
|
||||
|
||||
def __init__(
|
||||
self, logger: Logger, sqlalchemy_string: Optional[str] = None, *, ui: bool = False, pool: Optional[bool] = None, log: bool = True, **kwargs
|
||||
|
|
@ -75,6 +76,7 @@ class Database:
|
|||
"""Initialize the database"""
|
||||
self.logger = logger
|
||||
self.readonly = False
|
||||
self.last_connection_retry = None
|
||||
|
||||
if pool:
|
||||
self.logger.warning("The pool parameter is deprecated, it will be removed in the next version")
|
||||
|
|
@ -185,7 +187,7 @@ class Database:
|
|||
self.logger.error(f"Can't connect to database after {DATABASE_RETRY_TIMEOUT} seconds: {e}")
|
||||
_exit(1)
|
||||
|
||||
if "readonly" in str(e) or "read-only" in str(e) or "command denied" in str(e):
|
||||
if any(error in str(e) for error in self.READONLY_ERROR):
|
||||
if log:
|
||||
self.logger.warning("The database is read-only. Retrying in read-only mode in 5 seconds ...")
|
||||
self.sql_engine.dispose(close=True)
|
||||
|
|
@ -226,6 +228,7 @@ class Database:
|
|||
|
||||
def retry_connection(self, *, readonly: bool = False, fallback: bool = False, log: bool = True, **kwargs) -> None:
|
||||
"""Retry the connection to the database"""
|
||||
self.last_connection_retry = datetime.now()
|
||||
|
||||
if log:
|
||||
self.logger.debug(f"Retrying the connection to the database{' in read-only mode' if readonly else ''}{' with fallback' if fallback else ''} ...")
|
||||
|
|
@ -243,9 +246,10 @@ class Database:
|
|||
conn.execute(text("SELECT 1"))
|
||||
return
|
||||
|
||||
table_name = uuid4().hex
|
||||
with self.sql_engine.connect() as conn:
|
||||
conn.execute(text("CREATE TABLE IF NOT EXISTS test (id INT)"))
|
||||
conn.execute(text("DROP TABLE test"))
|
||||
conn.execute(text(f"CREATE TABLE IF NOT EXISTS test_{table_name} (id INT)"))
|
||||
conn.execute(text(f"DROP TABLE IF EXISTS test_{table_name}"))
|
||||
|
||||
@contextmanager
|
||||
def __db_session(self) -> Any:
|
||||
|
|
@ -265,7 +269,7 @@ class Database:
|
|||
if session:
|
||||
session.rollback()
|
||||
|
||||
if "readonly" in str(e) or "read-only" in str(e) or "command denied" in str(e):
|
||||
if any(error in str(e) for error in self.READONLY_ERROR):
|
||||
self.logger.warning("The database is read-only, retrying in read-only mode ...")
|
||||
try:
|
||||
self.retry_connection(readonly=True, pool_timeout=1)
|
||||
|
|
@ -1426,7 +1430,7 @@ class Database:
|
|||
|
||||
return message
|
||||
|
||||
def get_config(self, methods: bool = False, with_drafts: bool = False) -> Dict[str, Any]:
|
||||
def get_config(self, global_only: bool = False, methods: bool = False, with_drafts: bool = False) -> Dict[str, Any]:
|
||||
"""Get the config from the database"""
|
||||
with self.__db_session() as session:
|
||||
config = {}
|
||||
|
|
@ -1466,7 +1470,7 @@ class Database:
|
|||
if not with_drafts:
|
||||
services = services.filter_by(is_draft=False)
|
||||
|
||||
if is_multisite:
|
||||
if not global_only and is_multisite:
|
||||
for service in services:
|
||||
config[f"{service.id}_IS_DRAFT"] = "yes" if service.is_draft else "no"
|
||||
if methods:
|
||||
|
|
|
|||
|
|
@ -389,7 +389,7 @@ def inject_variables():
|
|||
plugins=app.config["CONFIG"].get_plugins(),
|
||||
pro_loading=ui_data.get("PRO_LOADING", False),
|
||||
bw_version=metadata["version"],
|
||||
is_readonly=app.config["DB"].readonly,
|
||||
is_readonly=ui_data.get("READONLY_MODE", False),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -442,11 +442,20 @@ def before_request():
|
|||
app.config["SCRIPT_NONCE"] = sha256(urandom(32)).hexdigest()
|
||||
|
||||
if not request.path.startswith(("/css", "/images", "/js", "/json", "/webfonts")):
|
||||
if app.config["DB"].database_uri and app.config["DB"].readonly:
|
||||
ui_data = get_ui_data()
|
||||
|
||||
if (
|
||||
app.config["DB"].database_uri
|
||||
and app.config["DB"].readonly
|
||||
and (
|
||||
datetime.now(timezone.utc) - datetime.fromisoformat(ui_data.get("LAST_DATABASE_RETRY", "1970-01-01T00:00:00")).replace(tzinfo=timezone.utc)
|
||||
> timedelta(minutes=1)
|
||||
)
|
||||
):
|
||||
try:
|
||||
app.config["DB"].retry_connection(pool_timeout=1)
|
||||
app.config["DB"].retry_connection(log=False)
|
||||
app.config["DB"].readonly = False
|
||||
ui_data["READONLY_MODE"] = False
|
||||
app.logger.info("The database is no longer read-only, defaulting to read-write mode")
|
||||
except BaseException:
|
||||
try:
|
||||
|
|
@ -457,12 +466,22 @@ def before_request():
|
|||
with suppress(BaseException):
|
||||
app.config["DB"].retry_connection(fallback=True, pool_timeout=1)
|
||||
app.config["DB"].retry_connection(fallback=True, log=False)
|
||||
app.config["DB"].readonly = True
|
||||
elif not app.config["DB"].readonly and request.method == "POST" and not ("/totp" in request.path or "/login" in request.path):
|
||||
ui_data["READONLY_MODE"] = True
|
||||
ui_data["LAST_DATABASE_RETRY"] = app.config["DB"].last_connection_retry.isoformat()
|
||||
elif not ui_data.get("READONLY_MODE", False) and request.method == "POST" and not ("/totp" in request.path or "/login" in request.path):
|
||||
try:
|
||||
app.config["DB"].test_write()
|
||||
ui_data["READONLY_MODE"] = False
|
||||
except BaseException:
|
||||
app.config["DB"].readonly = True
|
||||
ui_data["READONLY_MODE"] = True
|
||||
ui_data["LAST_DATABASE_RETRY"] = app.config["DB"].last_connection_retry.isoformat()
|
||||
|
||||
app.config["DB"].readonly = ui_data.get("READONLY_MODE", False)
|
||||
with LOCK:
|
||||
TMP_DATA_FILE.write_text(dumps(ui_data), encoding="utf-8")
|
||||
|
||||
if app.config["DB"].readonly:
|
||||
flash("Database connection is in read-only mode : no modification possible.", "error")
|
||||
|
||||
if current_user.is_authenticated:
|
||||
passed = True
|
||||
|
|
@ -478,11 +497,7 @@ def before_request():
|
|||
passed = False
|
||||
|
||||
if not passed:
|
||||
logout_user()
|
||||
session.clear()
|
||||
|
||||
if app.config["DB"].readonly:
|
||||
flash("Database connection is in read-only mode : no modification possible.", "error")
|
||||
return logout()
|
||||
|
||||
|
||||
@app.route("/", strict_slashes=False)
|
||||
|
|
@ -887,7 +902,7 @@ def instances():
|
|||
)
|
||||
|
||||
# Display instances
|
||||
config = app.config["CONFIG"].get_config()
|
||||
config = app.config["CONFIG"].get_config(global_only=True)
|
||||
override_instances = config["OVERRIDE_INSTANCES"]["value"] != ""
|
||||
instances = app.config["INSTANCES"].get_instances(override_instances=override_instances)
|
||||
return render_template("instances.html", title="Instances", instances=instances, username=current_user.get_id())
|
||||
|
|
@ -938,21 +953,21 @@ def services():
|
|||
custom_configs.append(variable)
|
||||
del variables[variable]
|
||||
|
||||
# config variable format is custom_config_<type>_<filename>
|
||||
# custom_config variable format is custom_config_<type>_<filename>
|
||||
# we want a list of dict with each dict containing type, filename, action and server name
|
||||
# after getting all configs, we want to save them after the end of current service action
|
||||
# to avoid create config for none existing service or in case editing server name
|
||||
format_configs = []
|
||||
for config in custom_configs:
|
||||
for custom_config in custom_configs:
|
||||
# first remove custom_config_ prefix
|
||||
config = config.split("custom_config_")[1]
|
||||
custom_config = custom_config.split("custom_config_")[1]
|
||||
# then split the config into type, filename, action
|
||||
config = config.split("_")
|
||||
custom_config = custom_config.split("_")
|
||||
# check if the config is valid
|
||||
if len(config) == 2 and config[0] in config_types:
|
||||
format_configs.append({"type": config[0], "filename": config[1], "action": operation, "server_name": server_name})
|
||||
if len(custom_config) == 2 and custom_config[0] in config_types:
|
||||
format_configs.append({"type": custom_config[0], "filename": custom_config[1], "action": operation, "server_name": server_name})
|
||||
else:
|
||||
return redirect_flash_error("Invalid custom config {config}", "services", True)
|
||||
return redirect_flash_error(f"Invalid custom config {custom_config}", "services", True)
|
||||
|
||||
if request.form["operation"] in ("new", "edit"):
|
||||
del variables["operation"]
|
||||
|
|
@ -1108,7 +1123,7 @@ def global_config():
|
|||
del variables["csrf_token"]
|
||||
|
||||
# Edit check fields and remove already existing ones
|
||||
config = app.config["CONFIG"].get_config(methods=True, with_drafts=True)
|
||||
config = app.config["CONFIG"].get_config(global_only=True, methods=True, with_drafts=True)
|
||||
services = config["SERVER_NAME"]["value"].split(" ")
|
||||
for variable, value in variables.copy().items():
|
||||
if variable in ("AUTOCONF_MODE", "SWARM_MODE", "KUBERNETES_MODE", "SERVER_NAME", "IS_LOADING", "IS_DRAFT") or variable.endswith("SCHEMA"):
|
||||
|
|
@ -1172,13 +1187,8 @@ def global_config():
|
|||
)
|
||||
)
|
||||
|
||||
global_config = app.config["CONFIG"].get_config()
|
||||
for service in global_config["SERVER_NAME"]["value"].split(" "):
|
||||
for key in global_config.copy():
|
||||
if key.startswith(f"{service}_"):
|
||||
global_config.pop(key)
|
||||
|
||||
# Display global config
|
||||
global_config = app.config["CONFIG"].get_config(global_only=True)
|
||||
return render_template("global_config.html", username=current_user.get_id(), global_config=global_config, dumped_global_config=dumps(global_config))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class Config:
|
|||
def get_settings(self) -> dict:
|
||||
return self.__settings
|
||||
|
||||
def get_config(self, methods: bool = True, with_drafts: bool = False) -> dict:
|
||||
def get_config(self, global_only: bool = False, methods: bool = True, with_drafts: bool = False) -> dict:
|
||||
"""Get the nginx variables env file and returns it as a dict
|
||||
|
||||
Returns
|
||||
|
|
@ -86,7 +86,7 @@ class Config:
|
|||
dict
|
||||
The nginx variables env file as a dict
|
||||
"""
|
||||
return self.__db.get_config(methods=methods, with_drafts=with_drafts)
|
||||
return self.__db.get_config(global_only=global_only, methods=methods, with_drafts=with_drafts)
|
||||
|
||||
def get_services(self, methods: bool = True, with_drafts: bool = False) -> list[dict]:
|
||||
"""Get nginx's services
|
||||
|
|
|
|||
Loading…
Reference in a new issue