feat: add persistent column preferences in database

This commit is contained in:
Théophile Diot 2024-11-21 11:09:54 +01:00
parent 313edb4df3
commit cb62f550ec
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
28 changed files with 696 additions and 175 deletions

View file

@ -1,6 +1,8 @@
#!/usr/bin/env python3
from sqlalchemy import TEXT, Boolean, Column, DateTime, Enum, ForeignKey, Identity, Integer, LargeBinary, String, UnicodeText
from json import dumps, loads
from typing import Any, Optional
from sqlalchemy import TEXT, Boolean, Column, DateTime, Enum, ForeignKey, Identity, Integer, LargeBinary, String, Text, TypeDecorator, UnicodeText
from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy.schema import UniqueConstraint
@ -310,6 +312,36 @@ class Metadata(Base):
## UI Models
THEMES_ENUM = Enum("light", "dark", name="themes_enum")
TABLES_ENUM = Enum("bans", "configs", "instances", "jobs", "plugins", "reports", "services", name="tables_enum")
class JSONText(TypeDecorator):
"""
Custom JSON type to serialize/deserialize dictionaries as strings.
Compatible with all databases (MariaDB, MySQL, PostgreSQL, SQLite).
Ensures JSON strings are sorted by keys for consistent storage.
"""
impl = Text # Stores JSON as a TEXT field in the database
def process_bind_param(self, value: Optional[dict], dialect: Any) -> Optional[str]:
"""
Convert a dictionary to a JSON string before saving to the database.
Sorts dictionary keys for consistent serialization.
"""
if value is None:
return None
# Serialize dictionary to a sorted JSON string
return dumps(dict(sorted(value.items())))
def process_result_value(self, value: Optional[str], dialect: Any) -> Optional[dict]:
"""
Convert a JSON string back to a dictionary after retrieving from the database.
"""
if value is None:
return None
# Deserialize JSON string to dictionary
return loads(value)
class Users(Base):
@ -331,6 +363,7 @@ class Users(Base):
roles = relationship("RolesUsers", back_populates="user", cascade="all")
recovery_codes = relationship("UserRecoveryCodes", back_populates="user", cascade="all")
sessions = relationship("UserSessions", back_populates="user", cascade="all")
columns_preferences = relationship("UserColumnsPreferences", back_populates="user", cascade="all")
list_roles: list[str] = []
list_permissions: list[str] = []
list_recovery_codes: list[str] = []
@ -396,3 +429,15 @@ class UserSessions(Base):
last_activity = Column(DateTime(timezone=True), nullable=False)
user = relationship("Users", back_populates="sessions")
class UserColumnsPreferences(Base):
__tablename__ = "bw_ui_user_columns_preferences"
__table_args__ = (UniqueConstraint("user_name", "table_name"),)
id = Column(Integer, Identity(start=1, increment=1), primary_key=True)
user_name = Column(String(256), ForeignKey("bw_ui_users.username", onupdate="cascade", ondelete="cascade"), nullable=False)
table_name = Column(TABLES_ENUM, nullable=False)
columns = Column(JSONText, nullable=False)
user = relationship("Users", back_populates="columns_preferences")

View file

@ -3,7 +3,7 @@ from logging import Logger
from os import sep
from os.path import join
from sys import path as sys_path
from typing import List, Literal, Optional, Union
from typing import Dict, List, Literal, Optional, Union
for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("api",), ("db",))]:
@ -14,9 +14,10 @@ from bcrypt import gensalt, hashpw
from sqlalchemy.orm import joinedload
from Database import Database # type: ignore
from model import Permissions, Roles, RolesPermissions, RolesUsers, UserRecoveryCodes, UserSessions # type: ignore
from model import Permissions, Roles, RolesPermissions, RolesUsers, UserColumnsPreferences, UserRecoveryCodes, UserSessions # type: ignore
from app.models.models import UiUsers
from app.utils import COLUMNS_PREFERENCES_DEFAULTS
class UIDatabase(Database):
@ -30,13 +31,20 @@ class UIDatabase(Database):
query = session.query(UiUsers).filter_by(username=username)
else:
query = session.query(UiUsers).filter_by(admin=True)
query = query.options(joinedload(UiUsers.roles), joinedload(UiUsers.recovery_codes))
query = query.options(joinedload(UiUsers.roles), joinedload(UiUsers.recovery_codes), joinedload(UiUsers.columns_preferences))
ui_user = query.first()
if not ui_user:
return None
elif not as_dict:
if not ui_user.columns_preferences and not self.readonly:
for table_name, columns in COLUMNS_PREFERENCES_DEFAULTS.items():
session.add(UserColumnsPreferences(user_name=ui_user.username, table_name=table_name, columns=columns))
session.commit()
session.refresh(ui_user)
if not as_dict:
return ui_user
ui_user_data = {
@ -102,6 +110,9 @@ class UIDatabase(Database):
for code in totp_recovery_codes or []:
session.add(UserRecoveryCodes(user_name=username, code=hashpw(code.encode("utf-8"), gensalt(rounds=10)).decode("utf-8")))
for table_name, columns in COLUMNS_PREFERENCES_DEFAULTS.items():
session.add(UserColumnsPreferences(user_name=username, table_name=table_name, columns=columns))
try:
session.commit()
except BaseException as e:
@ -141,6 +152,7 @@ class UIDatabase(Database):
session.query(RolesUsers).filter_by(user_name=old_username).update({"user_name": username})
session.query(UserRecoveryCodes).filter_by(user_name=old_username).update({"user_name": username})
session.query(UserSessions).filter_by(user_name=old_username).update({"user_name": username})
session.query(UserColumnsPreferences).filter_by(user_name=old_username).update({"user_name": username})
totp_changed = user.totp_secret != totp_secret
@ -176,6 +188,7 @@ class UIDatabase(Database):
session.query(RolesUsers).filter_by(user_name=username).delete()
session.query(UserRecoveryCodes).filter_by(user_name=username).delete()
session.query(UserColumnsPreferences).filter_by(user_name=username).delete()
session.delete(user)
try:
@ -417,3 +430,35 @@ class UIDatabase(Database):
return str(e)
return ""
def update_ui_user_columns_preferences(self, username: str, table_name: str, columns: Dict[str, bool]) -> str:
"""Update ui user columns preferences."""
with self._db_session() as session:
if self.readonly:
return "The database is read-only, the changes will not be saved"
user = session.query(UiUsers).filter_by(username=username).first()
if not user:
return f"User {username} doesn't exist"
columns_preferences = session.query(UserColumnsPreferences).filter_by(user_name=username, table_name=table_name).first()
if not columns_preferences:
return f"Table {table_name} doesn't exist"
columns_preferences.columns = columns
try:
session.commit()
except BaseException as e:
return str(e)
return ""
def get_ui_user_columns_preferences(self, username: str, table_name: str) -> Dict[str, bool]:
"""Get ui user columns preferences."""
with self._db_session() as session:
columns_preferences = session.query(UserColumnsPreferences).filter_by(user_name=username, table_name=table_name).first()
if not columns_preferences:
return COLUMNS_PREFERENCES_DEFAULTS.get(table_name, {})
return columns_preferences.columns

View file

@ -137,10 +137,16 @@ $(document).ready(function () {
};
const debounce = (func, delay) => {
let debounceTimer;
let timer = null;
return (...args) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => func.apply(this, args), delay);
// Clear the timer if the function is called again during the delay
if (timer) clearTimeout(timer);
// Start a new timer to invoke the function after the delay
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
@ -491,24 +497,21 @@ $(document).ready(function () {
$("#bans").removeClass("d-none");
$("#bans-waiting").addClass("visually-hidden");
const defaultColsVisibility = {
3: true,
4: true,
5: true,
6: true,
7: true,
};
const defaultColsVisibility = JSON.parse(
$("#columns_preferences_defaults").val().trim(),
);
var columnVisibility = localStorage.getItem("bw-bans-columns");
if (columnVisibility === null) {
columnVisibility = JSON.parse(JSON.stringify(defaultColsVisibility));
columnVisibility = JSON.parse($("#columns_preferences").val().trim());
} else {
columnVisibility = JSON.parse(columnVisibility);
Object.entries(columnVisibility).forEach(([key, value]) => {
bans_table.column(key).visible(value);
});
}
Object.entries(columnVisibility).forEach(([key, value]) => {
bans_table.column(key).visible(value);
});
bans_table.responsive.recalc();
bans_table.on("mouseenter", "td", function () {
@ -563,6 +566,34 @@ $(document).ready(function () {
}
});
const saveColumnsPreferences = debounce(() => {
const rootUrl = $("#home-path")
.val()
.trim()
.replace(/\/home$/, "/set_columns_preferences");
const csrfToken = $("#csrf_token").val();
const data = new FormData();
data.append("csrf_token", csrfToken);
data.append("table_name", "bans");
data.append("columns_preferences", JSON.stringify(columnVisibility));
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
console.log("Preferences saved successfully!");
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
}, 1000);
bans_table.on("column-visibility.dt", function (e, settings, column, state) {
if (column < 3 || column === 8) return;
columnVisibility[column] = state;
@ -576,6 +607,8 @@ $(document).ready(function () {
} else {
localStorage.setItem("bw-bans-columns", JSON.stringify(columnVisibility));
}
saveColumnsPreferences();
});
$(document).on("click", ".unban-ip", function () {

View file

@ -483,24 +483,21 @@ $(document).ready(function () {
$("#configs").removeClass("d-none");
$("#configs-waiting").addClass("visually-hidden");
const defaultColsVisibility = {
3: true,
4: true,
5: true,
6: true,
7: false,
};
const defaultColsVisibility = JSON.parse(
$("#columns_preferences_defaults").val().trim(),
);
var columnVisibility = localStorage.getItem("bw-configs-columns");
if (columnVisibility === null) {
columnVisibility = JSON.parse(JSON.stringify(defaultColsVisibility));
columnVisibility = JSON.parse($("#columns_preferences").val().trim());
} else {
columnVisibility = JSON.parse(columnVisibility);
Object.entries(columnVisibility).forEach(([key, value]) => {
configs_table.column(key).visible(value);
});
}
Object.entries(columnVisibility).forEach(([key, value]) => {
configs_table.column(key).visible(value);
});
configs_table.responsive.recalc();
configs_table.on("mouseenter", "td", function () {
@ -555,6 +552,48 @@ $(document).ready(function () {
}
});
const debounce = (func, delay) => {
let timer = null;
return (...args) => {
// Clear the timer if the function is called again during the delay
if (timer) clearTimeout(timer);
// Start a new timer to invoke the function after the delay
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
const saveColumnsPreferences = debounce(() => {
const rootUrl = $("#home-path")
.val()
.trim()
.replace(/\/home$/, "/set_columns_preferences");
const csrfToken = $("#csrf_token").val();
const data = new FormData();
data.append("csrf_token", csrfToken);
data.append("table_name", "configs");
data.append("columns_preferences", JSON.stringify(columnVisibility));
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
console.log("Preferences saved successfully!");
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
}, 1000);
configs_table.on(
"column-visibility.dt",
function (e, settings, column, state) {
@ -573,6 +612,8 @@ $(document).ready(function () {
JSON.stringify(columnVisibility),
);
}
saveColumnsPreferences();
},
);

View file

@ -600,25 +600,21 @@ $(document).ready(function () {
$("#instances").removeClass("d-none");
$("#instances-waiting").addClass("visually-hidden");
const defaultColsVisibility = {
3: false,
4: false,
5: true,
6: true,
7: true,
8: true,
};
const defaultColsVisibility = JSON.parse(
$("#columns_preferences_defaults").val().trim(),
);
var columnVisibility = localStorage.getItem("bw-instances-columns");
if (columnVisibility === null) {
columnVisibility = JSON.parse(JSON.stringify(defaultColsVisibility));
columnVisibility = JSON.parse($("#columns_preferences").val().trim());
} else {
columnVisibility = JSON.parse(columnVisibility);
Object.entries(columnVisibility).forEach(([key, value]) => {
instances_table.column(key).visible(value);
});
}
Object.entries(columnVisibility).forEach(([key, value]) => {
instances_table.column(key).visible(value);
});
instances_table.responsive.recalc();
instances_table.on("mouseenter", "td", function () {
@ -673,6 +669,48 @@ $(document).ready(function () {
}
});
const debounce = (func, delay) => {
let timer = null;
return (...args) => {
// Clear the timer if the function is called again during the delay
if (timer) clearTimeout(timer);
// Start a new timer to invoke the function after the delay
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
const saveColumnsPreferences = debounce(() => {
const rootUrl = $("#home-path")
.val()
.trim()
.replace(/\/home$/, "/set_columns_preferences");
const csrfToken = $("#csrf_token").val();
const data = new FormData();
data.append("csrf_token", csrfToken);
data.append("table_name", "instances");
data.append("columns_preferences", JSON.stringify(columnVisibility));
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
console.log("Preferences saved successfully!");
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
}, 1000);
instances_table.on(
"column-visibility.dt",
function (e, settings, column, state) {
@ -691,6 +729,8 @@ $(document).ready(function () {
JSON.stringify(columnVisibility),
);
}
saveColumnsPreferences();
},
);

View file

@ -376,24 +376,21 @@ $(document).ready(function () {
$("#jobs").removeClass("d-none");
$("#jobs-waiting").addClass("visually-hidden");
const defaultColsVisibility = {
3: true,
4: true,
5: true,
6: true,
7: true,
};
const defaultColsVisibility = JSON.parse(
$("#columns_preferences_defaults").val().trim(),
);
var columnVisibility = localStorage.getItem("bw-jobs-columns");
var columnVisibility = localStorage.getItem("bw-instances-columns");
if (columnVisibility === null) {
columnVisibility = JSON.parse(JSON.stringify(defaultColsVisibility));
columnVisibility = JSON.parse($("#columns_preferences").val().trim());
} else {
columnVisibility = JSON.parse(columnVisibility);
Object.entries(columnVisibility).forEach(([key, value]) => {
jobs_table.column(key).visible(value);
});
}
Object.entries(columnVisibility).forEach(([key, value]) => {
instances_table.column(key).visible(value);
});
jobs_table.responsive.recalc();
jobs_table.on("mouseenter", "td", function () {
@ -448,6 +445,48 @@ $(document).ready(function () {
}
});
const debounce = (func, delay) => {
let timer = null;
return (...args) => {
// Clear the timer if the function is called again during the delay
if (timer) clearTimeout(timer);
// Start a new timer to invoke the function after the delay
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
const saveColumnsPreferences = debounce(() => {
const rootUrl = $("#home-path")
.val()
.trim()
.replace(/\/home$/, "/set_columns_preferences");
const csrfToken = $("#csrf_token").val();
const data = new FormData();
data.append("csrf_token", csrfToken);
data.append("table_name", "jobs");
data.append("columns_preferences", JSON.stringify(columnVisibility));
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
console.log("Preferences saved successfully!");
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
}, 1000);
jobs_table.on("column-visibility.dt", function (e, settings, column, state) {
if (column < 3 || column === 8) return;
columnVisibility[column] = state;
@ -461,6 +500,8 @@ $(document).ready(function () {
} else {
localStorage.setItem("bw-jobs-columns", JSON.stringify(columnVisibility));
}
saveColumnsPreferences();
});
$(document).on("click", ".show-history", function () {

View file

@ -475,25 +475,21 @@ $(document).ready(function () {
$("#plugins").removeClass("d-none");
$("#plugins-waiting").addClass("visually-hidden");
const defaultColsVisibility = {
2: false,
4: false,
5: true,
6: true,
7: true,
8: true,
};
const defaultColsVisibility = JSON.parse(
$("#columns_preferences_defaults").val().trim(),
);
var columnVisibility = localStorage.getItem("bw-plugins-columns");
if (columnVisibility === null) {
columnVisibility = JSON.parse(JSON.stringify(defaultColsVisibility));
columnVisibility = JSON.parse($("#columns_preferences").val().trim());
} else {
columnVisibility = JSON.parse(columnVisibility);
Object.entries(columnVisibility).forEach(([key, value]) => {
plugins_table.column(key).visible(value);
});
}
Object.entries(columnVisibility).forEach(([key, value]) => {
plugins_table.column(key).visible(value);
});
plugins_table.responsive.recalc();
plugins_table.on("mouseenter", "td", function () {
@ -548,6 +544,48 @@ $(document).ready(function () {
}
});
const debounce = (func, delay) => {
let timer = null;
return (...args) => {
// Clear the timer if the function is called again during the delay
if (timer) clearTimeout(timer);
// Start a new timer to invoke the function after the delay
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
const saveColumnsPreferences = debounce(() => {
const rootUrl = $("#home-path")
.val()
.trim()
.replace(/\/home$/, "/set_columns_preferences");
const csrfToken = $("#csrf_token").val();
const data = new FormData();
data.append("csrf_token", csrfToken);
data.append("table_name", "plugins");
data.append("columns_preferences", JSON.stringify(columnVisibility));
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
console.log("Preferences saved successfully!");
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
}, 1000);
plugins_table.on(
"column-visibility.dt",
function (e, settings, column, state) {
@ -566,6 +604,8 @@ $(document).ready(function () {
JSON.stringify(columnVisibility),
);
}
saveColumnsPreferences();
},
);

View file

@ -501,33 +501,68 @@ $(function () {
$("#reports").removeClass("d-none");
$("#reports-waiting").addClass("visually-hidden");
const defaultColsVisibility = {
3: true,
4: false,
5: false,
6: false,
7: false,
8: true,
9: true,
10: false,
11: true,
};
const defaultColsVisibility = JSON.parse(
$("#columns_preferences_defaults").val().trim(),
);
var columnVisibility = localStorage.getItem("bw-reports-columns");
if (columnVisibility === null) {
columnVisibility = JSON.parse(JSON.stringify(defaultColsVisibility));
columnVisibility = JSON.parse($("#columns_preferences").val().trim());
} else {
columnVisibility = JSON.parse(columnVisibility);
Object.entries(columnVisibility).forEach(([key, value]) => {
reports_table.column(key).visible(value);
});
}
Object.entries(columnVisibility).forEach(([key, value]) => {
reports_table.column(key).visible(value);
});
reports_table.responsive.recalc();
// Update tooltips after table draw
reports_table.on("draw.dt", updateCountryTooltips);
const debounce = (func, delay) => {
let timer = null;
return (...args) => {
// Clear the timer if the function is called again during the delay
if (timer) clearTimeout(timer);
// Start a new timer to invoke the function after the delay
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
const saveColumnsPreferences = debounce(() => {
const rootUrl = $("#home-path")
.val()
.trim()
.replace(/\/home$/, "/set_columns_preferences");
const csrfToken = $("#csrf_token").val();
const data = new FormData();
data.append("csrf_token", csrfToken);
data.append("table_name", "reports");
data.append("columns_preferences", JSON.stringify(columnVisibility));
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
console.log("Preferences saved successfully!");
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
}, 1000);
reports_table.on(
"column-visibility.dt",
function (e, settings, column, state) {
@ -546,6 +581,8 @@ $(function () {
JSON.stringify(columnVisibility),
);
}
saveColumnsPreferences();
},
);

View file

@ -491,23 +491,21 @@ $(function () {
$("#services").removeClass("d-none");
$("#services-waiting").addClass("visually-hidden");
const defaultColsVisibility = {
3: true,
4: true,
5: true,
6: true,
};
const defaultColsVisibility = JSON.parse(
$("#columns_preferences_defaults").val().trim(),
);
var columnVisibility = localStorage.getItem("bw-services-columns");
if (columnVisibility === null) {
columnVisibility = JSON.parse(JSON.stringify(defaultColsVisibility));
columnVisibility = JSON.parse($("#columns_preferences").val().trim());
} else {
columnVisibility = JSON.parse(columnVisibility);
Object.entries(columnVisibility).forEach(([key, value]) => {
services_table.column(key).visible(value);
});
}
Object.entries(columnVisibility).forEach(([key, value]) => {
services_table.column(key).visible(value);
});
services_table.responsive.recalc();
services_table.on("mouseenter", "td", function () {
@ -562,6 +560,48 @@ $(function () {
}
});
const debounce = (func, delay) => {
let timer = null;
return (...args) => {
// Clear the timer if the function is called again during the delay
if (timer) clearTimeout(timer);
// Start a new timer to invoke the function after the delay
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
const saveColumnsPreferences = debounce(() => {
const rootUrl = $("#home-path")
.val()
.trim()
.replace(/\/home$/, "/set_columns_preferences");
const csrfToken = $("#csrf_token").val();
const data = new FormData();
data.append("csrf_token", csrfToken);
data.append("table_name", "services");
data.append("columns_preferences", JSON.stringify(columnVisibility));
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
console.log("Preferences saved successfully!");
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
}, 1000);
services_table.on(
"column-visibility.dt",
function (e, settings, column, state) {
@ -580,6 +620,8 @@ $(function () {
JSON.stringify(columnVisibility),
);
}
saveColumnsPreferences();
},
);

View file

@ -423,6 +423,42 @@ $(document).ready(() => {
updateNotificationsBadge();
});
const debounce = (func, delay) => {
let timer = null;
return (...args) => {
// Clear the timer if the function is called again during the delay
if (timer) clearTimeout(timer);
// Start a new timer to invoke the function after the delay
timer = setTimeout(() => {
func(...args);
}, delay);
};
};
const saveTheme = debounce((rootUrl, theme) => {
const csrfToken = $("#csrf_token").val();
const data = new FormData();
data.append("theme", theme);
data.append("csrf_token", csrfToken);
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
}, 1000);
$("#dark-mode-toggle").on("change", function () {
// If endpoint is "setup", ignore the theme change
if (window.location.pathname.includes("/setup")) return;
@ -463,30 +499,14 @@ $(document).ready(() => {
}
$("#theme").val(darkMode ? "dark" : "light");
const rootUrl = $(this)
.data("root-url")
.replace(/\/profile$/, "/set_theme");
const csrfToken = $("#csrf_token").val();
const theme = darkMode ? "dark" : "light";
localStorage.setItem("theme", theme); // Save user preference
const data = new FormData();
data.append("theme", theme);
data.append("csrf_token", csrfToken);
fetch(rootUrl, {
method: "POST",
body: data,
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
// Handle success, redirect, etc.
})
.catch((error) => {
console.error("There was a problem with the fetch operation:", error);
});
saveTheme(
$(this)
.data("root-url")
.replace(/\/profile$/, "/set_theme"),
theme,
);
});
});

View file

@ -3,6 +3,10 @@
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 pb-8 min-vh-70">
<input type="hidden" id="bans_number" value="{{ bans|length }}" />
<textarea type="hidden"
id="columns_preferences_defaults"
class="visually-hidden">{{ columns_preferences_defaults['bans']|tojson }}</textarea>
<textarea type="hidden" id="columns_preferences" class="visually-hidden">{{ columns_preferences|tojson }}</textarea>
<input type="hidden"
id="csrf_token"
name="csrf_token"

View file

@ -1,4 +1,3 @@
{% set current_endpoint = request.path.split("/")[-1] %}
{% set theme = theme or "light" %}
{% set pro_diamond_url = url_for('static', filename='img/diamond.svg') %}
{% set avatar_url = url_for('static', filename='img/avatar_profil_BW.png' if theme == 'light' else 'img/avatar_profil_BW-white.png') %}
@ -125,12 +124,21 @@
nonce="{{ script_nonce }}"></script>
</head>
<body>
<input type="hidden" id="home-path" value="{{ url_for('home') }}" />
<input type="hidden" id="is-read-only" value="{{ is_readonly }}" />
<input type="hidden" id="theme" value="{{ theme }}" />
<input type="hidden" id="bw-logo" value="{{ url_for('static', filename='img/logo-menu.png') }}" />
<input type="hidden" id="bw-logo-white" value="{{ url_for('static', filename='img/logo-menu-white.png') }}" />
<input type="hidden" id="avatar-url" value="{{ url_for('static', filename='img/avatar_profil_BW.png') }}" />
<input type="hidden" id="avatar-url-white" value="{{ url_for('static', filename='img/avatar_profil_BW-white.png') }}" />
<input type="hidden"
id="bw-logo"
value="{{ url_for('static', filename='img/logo-menu.png') }}" />
<input type="hidden"
id="bw-logo-white"
value="{{ url_for('static', filename='img/logo-menu-white.png') }}" />
<input type="hidden"
id="avatar-url"
value="{{ url_for('static', filename='img/avatar_profil_BW.png') }}" />
<input type="hidden"
id="avatar-url-white"
value="{{ url_for('static', filename='img/avatar_profil_BW-white.png') }}" />
<!-- prettier-ignore -->
{% if current_endpoint != "loading" %}
{% include "flash.html" %}

View file

@ -17,7 +17,8 @@
value="{{ csrf_token() }}" />
<p id="cache-waiting"
class="text-center relative w-full p-2 text-primary rounded-lg fw-bold">Loading cache files...</p>
<table id="cache" class="table responsive nowrap position-relative w-100 d-none">
<table id="cache"
class="table responsive nowrap position-relative w-100 d-none">
<thead>
<tr>
<th data-bs-toggle="tooltip"

View file

@ -9,6 +9,10 @@
id="configs_service_selection"
value="{{ config_service }}" />
<input type="hidden" id="configs_type_selection" value="{{ config_type }}" />
<textarea type="hidden"
id="columns_preferences_defaults"
class="visually-hidden">{{ columns_preferences_defaults['configs']|tojson }}</textarea>
<textarea type="hidden" id="columns_preferences" class="visually-hidden">{{ columns_preferences|tojson }}</textarea>
<input type="hidden"
id="csrf_token"
name="csrf_token"

View file

@ -19,6 +19,10 @@
</div>
<div class="card table-responsive text-nowrap p-4 pb-8 min-vh-70">
<input type="hidden" id="instances_number" value="{{ instances|length }}" />
<textarea type="hidden"
id="columns_preferences_defaults"
class="visually-hidden">{{ columns_preferences_defaults['instances']|tojson }}</textarea>
<textarea type="hidden" id="columns_preferences" class="visually-hidden">{{ columns_preferences|tojson }}</textarea>
<input type="hidden"
id="csrf_token"
name="csrf_token"

View file

@ -3,6 +3,10 @@
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 min-vh-70">
<input type="hidden" id="job_number" value="{{ jobs|length }}" />
<textarea type="hidden"
id="columns_preferences_defaults"
class="visually-hidden">{{ columns_preferences_defaults['jobs']|tojson }}</textarea>
<textarea type="hidden" id="columns_preferences" class="visually-hidden">{{ columns_preferences|tojson }}</textarea>
<input type="hidden"
id="csrf_token"
name="csrf_token"

View file

@ -9,9 +9,9 @@
<div id="banner-container">
<p id="banner-text" class="mb-0 slide-in">
Get the most of BunkerWeb by upgrading to the PRO version. More info and free trial <a class="light-href text-white-80"
target="_blank"
rel="noopener"
href="https://panel.bunkerweb.io/?utm_campaign=self&utm_source=banner#pro">here</a>.
target="_blank"
rel="noopener"
href="https://panel.bunkerweb.io/?utm_campaign=self&utm_source=banner#pro">here</a>.
</p>
</div>
</div>

View file

@ -39,8 +39,7 @@
value="{{ csrf_token() }}" />
<i class="menu-icon tf-icons dark-mode-toggle-icon bx bx-{% if theme == "light" %}sun{% else %}moon{% endif %}"></i>
<div class="d-flex align-items-center justify-content-center w-100 h-100">
<label class="setting-checkbox-label me-2 mb-0"
for="dark-mode-toggle">Light</label>
<label class="setting-checkbox-label me-2 mb-0" for="dark-mode-toggle">Light</label>
<div class="form-check form-switch mb-0"
{% if is_readonly %}data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="The database is in readonly mode, therefore the theme cannot be changed"{% endif %}>
<input id="dark-mode-toggle"
@ -52,8 +51,7 @@
{% if theme == "dark" %}checked{% endif %}
{% if is_readonly %}disabled{% endif %} />
</div>
<label class="setting-checkbox-label mb-0"
for="dark-mode-toggle">Dark</label>
<label class="setting-checkbox-label mb-0" for="dark-mode-toggle">Dark</label>
</div>
</div>
</li>

View file

@ -66,23 +66,23 @@
<!-- /Buttons -->
<ul class="navbar-nav flex-row align-items-center ms-auto">
{% if not is_pro_version %}
<li class="nav-item lh-1 me-4">
<div class="buy-now courier-prime">
<a class="btn btn-responsive btn-buy-now"
role="button"
aria-pressed="true"
href="https://panel.bunkerweb.io/order/bunkerweb-pro?utm_campaign=self&utm_source=ui"
target="_blank"
rel="noopener">
<span class="me-1 me-md-2 d-flex h-100 justify-content-center align-items-center">
<img src="{{ url_for('static', filename='img/diamond-white.svg') }}"
alt="Pro plugin"
width="18px"
height="15.5px">
</span>
Upgrade to PRO</a>
</div>
</li>
<li class="nav-item lh-1 me-4">
<div class="buy-now courier-prime">
<a class="btn btn-responsive btn-buy-now"
role="button"
aria-pressed="true"
href="https://panel.bunkerweb.io/order/bunkerweb-pro?utm_campaign=self&utm_source=ui"
target="_blank"
rel="noopener">
<span class="me-1 me-md-2 d-flex h-100 justify-content-center align-items-center">
<img src="{{ url_for('static', filename='img/diamond-white.svg') }}"
alt="Pro plugin"
width="18px"
height="15.5px">
</span>
Upgrade to PRO</a>
</div>
</li>
{% endif %}
<!-- Stars -->
<li class="d-none d-md-inline nav-item lh-1 me-4">

View file

@ -4,6 +4,10 @@
<div class="card table-responsive text-nowrap p-4 min-vh-70">
<input type="hidden" id="plugins_number" value="{{ plugins|length }}" />
<input type="hidden" id="pro_diamond_url" value="{{ pro_diamond_url }}" />
<textarea type="hidden"
id="columns_preferences_defaults"
class="visually-hidden">{{ columns_preferences_defaults['plugins']|tojson }}</textarea>
<textarea type="hidden" id="columns_preferences" class="visually-hidden">{{ columns_preferences|tojson }}</textarea>
<input type="hidden"
id="csrf_token"
name="csrf_token"

View file

@ -181,10 +181,7 @@
<div class="row g-3">
<div class="col-md-12 form-floating">
<select class="form-select" id="theme" name="theme">
<option value="light"
{% if theme == "light" %}selected{% endif %}>
Light
</option>
<option value="light" {% if theme == "light" %}selected{% endif %}>Light</option>
<option value="dark" {% if theme == "dark" %}selected{% endif %}>Dark</option>
</select>
<label for="theme">Theme</label>

View file

@ -5,6 +5,14 @@
{% set base_flags_url = url_for('static', filename='img/flags') %}
<input type="hidden" id="reports_number" value="{{ reports|length }}" />
<input type="hidden" id="base_flags_url" value="{{ base_flags_url }}" />
<textarea type="hidden"
id="columns_preferences_defaults"
class="visually-hidden">{{ columns_preferences_defaults['reports']|tojson }}</textarea>
<textarea type="hidden" id="columns_preferences" class="visually-hidden">{{ columns_preferences|tojson }}</textarea>
<input type="hidden"
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<p id="reports-waiting"
class="text-center relative w-full p-2 text-primary rounded-lg fw-bold">Loading reports...</p>
<table id="reports"

View file

@ -3,13 +3,18 @@
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 pb-8 min-vh-70">
<input type="hidden" id="services_number" value="{{ services|length }}" />
<textarea type="hidden"
id="columns_preferences_defaults"
class="visually-hidden">{{ columns_preferences_defaults['services']|tojson }}</textarea>
<textarea type="hidden" id="columns_preferences" class="visually-hidden">{{ columns_preferences|tojson }}</textarea>
<input type="hidden"
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<p id="services-waiting"
class="text-center relative w-full p-2 text-primary rounded-lg fw-bold">Loading services...</p>
<table id="services" class="table responsive nowrap position-relative w-100 d-none">
<table id="services"
class="table responsive nowrap position-relative w-100 d-none">
<thead>
<tr>
<th data-bs-toggle="tooltip"

View file

@ -459,7 +459,8 @@
{% if lets_encrypt_staging == "yes" %}checked{% endif %} />
</div>
</div>
<div class="col-4 pb-3"{% if lets_encrypt_challenge == 'http' %} data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="Wildcard certificates are only supported with DNS challenges."{% endif %}>
<div class="col-4 pb-3"
{% if lets_encrypt_challenge == 'http' %} data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="Wildcard certificates are only supported with DNS challenges."{% endif %}>
<div class="d-flex justify-content-between align-items-center">
<label id="label-USE_LETS_ENCRYPT_WILDCARD"
for="USE_LETS_ENCRYPT_WILDCARD"
@ -491,7 +492,7 @@
role="switch"
aria-labelledby="label-USE_LETS_ENCRYPT_WILDCARD"
{% if lets_encrypt_wildcard == "yes" %}checked{% endif %}
{% if lets_encrypt_challenge == 'http' %}disabled{% endif %}/>
{% if lets_encrypt_challenge == 'http' %}disabled{% endif %} />
</div>
</div>
<div class="col-md-6 pb-3">
@ -564,7 +565,8 @@
</option>
</select>
</div>
<div class="col-md-6 pb-3"{% if lets_encrypt_challenge == 'http' %} data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="DNS provider is only supported with DNS challenges."{% endif %}>
<div class="col-md-6 pb-3"
{% if lets_encrypt_challenge == 'http' %} data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="DNS provider is only supported with DNS challenges."{% endif %}>
<div class="d-flex justify-content-between align-items-center">
<label id="label-LETS_ENCRYPT_DNS_PROVIDER"
for="LETS_ENCRYPT_DNS_PROVIDER"
@ -652,7 +654,8 @@
</option>
</select>
</div>
<div class="col-md-6 pb-3"{% if lets_encrypt_challenge == 'http' %} data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="DNS propagation is only supported with DNS challenges."{% endif %}>
<div class="col-md-6 pb-3"
{% if lets_encrypt_challenge == 'http' %} data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="DNS propagation is only supported with DNS challenges."{% endif %}>
<div class="d-flex justify-content-between align-items-center">
<label id="label-LETS_ENCRYPT_DNS_PROPAGATION"
for="LETS_ENCRYPT_DNS_PROPAGATION"
@ -685,7 +688,8 @@
pattern="^(default|\d+)$"
{% if lets_encrypt_challenge == 'http' %}disabled{% endif %} />
</div>
<div class="col-12 pb-3"{% if lets_encrypt_challenge == 'http' %} data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="Credentials are only supported with DNS challenges."{% endif %}>
<div class="col-12 pb-3"
{% if lets_encrypt_challenge == 'http' %} data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="Credentials are only supported with DNS challenges."{% endif %}>
<div class="d-flex justify-content-between align-items-center">
<label id="label-LETS_ENCRYPT_DNS_CREDENTIAL_ITEMS"
for="LETS_ENCRYPT_DNS_CREDENTIAL_ITEMS"

View file

@ -1,25 +1,25 @@
{% extends "base.html" %}
{% block page %}
<!-- Content -->
<!-- Dark Mode Toggle - Enhanced Floating Button -->
<!-- Dark Mode Toggle - Enhanced Floating Button -->
<div class="theme-toggle position-fixed top-0 end-0 p-6"
style="z-index: 1030">
<div class="toggle-container d-flex align-items-center bg-white p-3 rounded-pill shadow-lg">
<label class="setting-checkbox-label pe-2 mb-0 fw-bold text-secondary"
for="dark-mode-toggle">Light</label>
<div class="form-switch">
<input id="dark-mode-toggle"
name="dark-mode-toggle"
class="form-check-input"
type="checkbox"
role="switch"
{% if theme == "dark" %}checked{% endif %} />
</div>
<label class="setting-checkbox-label mb-0 fw-bold text-secondary"
for="dark-mode-toggle">Dark</label>
</div>
</div>
<!-- /Dark Mode Toggle -->
style="z-index: 1030">
<div class="toggle-container d-flex align-items-center bg-white p-3 rounded-pill shadow-lg">
<label class="setting-checkbox-label pe-2 mb-0 fw-bold text-secondary"
for="dark-mode-toggle">Light</label>
<div class="form-switch">
<input id="dark-mode-toggle"
name="dark-mode-toggle"
class="form-check-input"
type="checkbox"
role="switch"
{% if theme == "dark" %}checked{% endif %} />
</div>
<label class="setting-checkbox-label mb-0 fw-bold text-secondary"
for="dark-mode-toggle">Dark</label>
</div>
</div>
<!-- /Dark Mode Toggle -->
<div class="bg-{% if theme == 'light' %}light{% else %}dark{% endif %}-subtle">
<div class="login-background">
<div class="container-xxl">

View file

@ -23,6 +23,63 @@ LOGGER = setup_logger("UI", getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "INFO
USER_PASSWORD_RX = re_compile(r"^(?=.*?\p{Lowercase_Letter})(?=.*?\p{Uppercase_Letter})(?=.*?\d)(?=.*?[ -~]).{8,}$")
PLUGIN_NAME_RX = re_compile(r"^[\w.-]{4,64}$")
COLUMNS_PREFERENCES_DEFAULTS = {
"bans": {
"3": True,
"4": True,
"5": True,
"6": True,
"7": True,
},
"configs": {
"3": True,
"4": True,
"5": True,
"6": True,
"7": False,
},
"instances": {
"3": False,
"4": False,
"5": True,
"6": True,
"7": True,
"8": True,
},
"jobs": {
"3": True,
"4": True,
"5": True,
"6": True,
"7": True,
},
"plugins": {
"2": False,
"4": False,
"5": True,
"6": True,
"7": True,
"8": True,
},
"reports": {
"3": True,
"4": False,
"5": False,
"6": False,
"7": False,
"8": True,
"9": True,
"10": False,
"11": True,
},
"services": {
"3": True,
"4": True,
"5": True,
"6": True,
},
}
def stop_gunicorn():
p = Popen(["pgrep", "-f", "gunicorn"], stdout=PIPE)

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3
from contextlib import suppress
from datetime import datetime, timedelta
from json import dumps
from json import dumps, loads
from os import getenv, sep
from os.path import join
from secrets import token_urlsafe
@ -45,6 +45,7 @@ from app.routes.totp import totp
from app.dependencies import BW_CONFIG, DATA, DB
from app.models.models import AnonymousUser
from app.utils import (
COLUMNS_PREFERENCES_DEFAULTS,
TMP_DIR,
LOGGER,
flash,
@ -139,8 +140,9 @@ with app.app_context():
@app.context_processor
def inject_variables():
current_endpoint = request.path.split("/")[-1]
if request.path.startswith(("/check_reloading", "/setup", "/loading", "/login", "/totp")):
return dict(script_nonce=app.config["SCRIPT_NONCE"])
return dict(current_endpoint=current_endpoint, script_nonce=app.config["SCRIPT_NONCE"])
DATA.load_from_file()
metadata = DB.get_metadata()
@ -164,7 +166,8 @@ def inject_variables():
flash("The last changes have been applied successfully.", "success")
DATA["CONFIG_CHANGED"] = False
return dict(
data = dict(
current_endpoint=current_endpoint,
script_nonce=app.config["SCRIPT_NONCE"],
bw_version=metadata["version"],
latest_version=DATA.get("LATEST_VERSION", "unknown"),
@ -177,8 +180,14 @@ def inject_variables():
flash_messages=session.get("flash_messages", []),
is_readonly=DATA.get("READONLY_MODE", False),
theme=current_user.theme if current_user.is_authenticated else "dark",
columns_preferences_defaults=COLUMNS_PREFERENCES_DEFAULTS,
)
if current_endpoint in COLUMNS_PREFERENCES_DEFAULTS:
data["columns_preferences"] = DB.get_ui_user_columns_preferences(current_user.get_id(), current_endpoint)
return data
@login_manager.user_loader
def load_user(username):
@ -447,7 +456,7 @@ def check_reloading():
@login_required
def set_theme():
if DB.readonly or request.form["theme"] not in ("dark", "light"):
return
return Response(status=400, response=dumps({"message": "Bad request"}), content_type="application/json")
user_data = {
"username": current_user.get_id(),
@ -461,6 +470,36 @@ def set_theme():
ret = DB.update_ui_user(**user_data, old_username=current_user.get_id())
if ret:
LOGGER.error(f"Couldn't update the user {current_user.get_id()}: {ret}")
return Response(status=500, response=dumps({"message": "Internal server error"}), content_type="application/json")
return Response(status=200, response=dumps({"message": "ok"}), content_type="application/json")
@app.route("/set_columns_preferences", methods=["POST"])
@login_required
def set_columns_preferences():
table_name = request.form.get("table_name")
columns_preferences = request.form.get("columns_preferences", "{}")
try:
columns_preferences = loads(columns_preferences)
except BaseException:
return Response(status=400, response=dumps({"message": "Bad request"}), content_type="application/json")
LOGGER.debug(f"Setting columns preferences for {table_name}: {columns_preferences}")
LOGGER.debug(f"Default columns preferences for {table_name}: {COLUMNS_PREFERENCES_DEFAULTS.get(table_name, {})}")
if (
DB.readonly
or table_name not in COLUMNS_PREFERENCES_DEFAULTS
or any(column not in COLUMNS_PREFERENCES_DEFAULTS[table_name] for column in columns_preferences)
):
return Response(status=400, response=dumps({"message": "Bad request"}), content_type="application/json")
ret = DB.update_ui_user_columns_preferences(current_user.get_id(), table_name, columns_preferences)
if ret:
LOGGER.error(f"Couldn't update the user {current_user.get_id()}'s columns preferences: {ret}")
return Response(status=500, response=dumps({"message": "Internal server error"}), content_type="application/json")
return Response(status=200, response=dumps({"message": "ok"}), content_type="application/json")