Finished profile page + Start working on instances page in web UI

This commit is contained in:
Théophile Diot 2024-08-29 16:54:11 +02:00
parent 202dcfbadd
commit 68212ac0ee
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
21 changed files with 1305 additions and 543 deletions

View file

@ -479,10 +479,18 @@ class UIDatabase(Database):
return ""
def get_ui_user_sessions(self, username: str) -> List[UserSessions]:
def get_ui_user_sessions(self, username: str, current_session_id: Optional[str] = None) -> List[UserSessions]:
"""Get ui user sessions."""
with self._db_session() as session:
return session.query(UserSessions).filter_by(user_name=username).order_by(UserSessions.creation_date.desc()).all()
if current_session_id:
return (
session.query(UserSessions)
.filter_by(user_name=username)
.order_by(UserSessions.id == current_session_id, UserSessions.creation_date.desc())
.all()
)
else:
return session.query(UserSessions).filter_by(user_name=username).order_by(UserSessions.creation_date.desc()).all()
def delete_ui_user_old_sessions(self, username: str) -> str:
"""Delete ui user old sessions."""

View file

@ -15,42 +15,12 @@ instances = Blueprint("instances", __name__)
@instances.route("/instances", methods=["GET"])
@login_required
def instances_page():
instances = []
instances_types = set()
instances_methods = set()
instances_healths = set()
for instance in BW_INSTANCES_UTILS.get_instances():
instances.append(
{
"hostname": instance.hostname,
"name": instance.name,
"method": instance.method,
"health": instance.status,
"type": instance.type,
"creation_date": instance.creation_date.strftime("%Y-%m-%d at %H:%M:%S %Z"),
"last_seen": instance.last_seen.strftime("%Y-%m-%d at %H:%M:%S %Z"),
}
)
instances_types.add(instance.type)
instances_methods.add(instance.method)
instances_healths.add(instance.status)
# builder = instances_builder(instances, list(instances_types), list(instances_methods), list(instances_healths))
# return render_template("instances.html", title="Instances", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii"))
return render_template("instances.html") # TODO
return render_template("instances.html", instances=BW_INSTANCES_UTILS.get_instances())
@instances.route("/instances/new", methods=["PUT"])
@login_required
def instances_new():
verify_data_in_form(
data={"csrf_token": None},
err_message="Missing csrf_token parameter on /instances/new.",
redirect_url="instances",
next=True,
)
verify_data_in_form(
data={"instance_hostname": None},
err_message="Missing instance hostname parameter on /instances/new.",
@ -85,57 +55,41 @@ def instances_new():
return redirect(url_for("loading", next=url_for("instances.instances_page"), message=f"Creating new instance {instance['hostname']}"))
@instances.route("/instances/<string:instance_hostname>", methods=["DELETE"])
@instances.route("/instances/<string:instance_hostname>/<string:action>", methods=["POST"])
@login_required
def instances_delete(instance_hostname: str):
verify_data_in_form(
data={"csrf_token": None},
err_message="Missing csrf_token parameter on /instances/delete.",
redirect_url="instances",
next=True,
)
def instances_action(instance_hostname: str, action: Literal["ping", "reload", "stop", "delete"]): # TODO: see if we can support start and restart
if action == "delete":
delete_instance = None
for instance in BW_INSTANCES_UTILS.get_instances():
if instance.hostname == instance_hostname:
delete_instance = instance
break
delete_instance = None
for instance in BW_INSTANCES_UTILS.get_instances():
if instance.hostname == instance_hostname:
delete_instance = instance
break
if not delete_instance:
return handle_error(f"Instance {instance_hostname} not found.", "instances", True)
if delete_instance.method != "ui":
return handle_error(f"Instance {instance_hostname} is not a UI instance.", "instances", True)
if not delete_instance:
return handle_error(f"Instance {instance_hostname} not found.", "instances", True)
if delete_instance.method != "ui":
return handle_error(f"Instance {instance_hostname} is not a UI instance.", "instances", True)
ret = DB.delete_instance(instance_hostname)
if ret:
return handle_error(f"Couldn't delete the instance in the database: {ret}", "instances", True)
return redirect(url_for("loading", next=url_for("instances.instances_page"), message=f"Deleting instance {instance_hostname}"))
@instances.route("/instances/<string:action>", methods=["POST"])
@login_required
def instances_action(action: Literal["ping", "reload", "stop"]): # TODO: see if we can support start and restart
verify_data_in_form(
data={"instance_hostname": None, "csrf_token": None},
err_message="Missing instance hostname parameter on /instances/reload.",
redirect_url="instances",
next=True,
)
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(
target=manage_bunkerweb,
name=f"Reloading instance {request.form['instance_hostname']}",
args=("instances", request.form["instance_hostname"]),
kwargs={"operation": action, "threaded": True},
).start()
ret = DB.delete_instance(instance_hostname)
if ret:
return handle_error(f"Couldn't delete the instance in the database: {ret}", "instances", True)
else:
DATA["RELOADING"] = True
DATA["LAST_RELOAD"] = time()
Thread(
target=manage_bunkerweb,
args=("instances", instance_hostname),
kwargs={"operation": action, "threaded": True},
).start()
return redirect(
url_for(
"loading",
next=url_for("instances.instances_page"),
message=(f"{action.title()}ing" if action != "stop" else "Stopping") + " instance",
message=(
f"{action.title()}ing"
if action not in ("delete", "stop")
else ("Deleting" if action == "delete" else "Stopping") + f" instance {instance_hostname}"
),
)
)

View file

@ -1,13 +1,14 @@
from flask import Blueprint, flash, redirect, render_template, request, url_for, session
from typing import Dict, Generator, Tuple, Union
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, stream_with_context, url_for, session
from flask_login import current_user, login_required, logout_user
from user_agents import parse
from app.models.totp import totp as TOTP
from app.dependencies import DB
from app.dependencies import DATA, DB
from app.utils import USER_PASSWORD_RX, gen_password_hash
from app.routes.utils import handle_error, verify_data_in_form
from app.routes.utils import cors_required, handle_error, verify_data_in_form
profile = Blueprint("profile", __name__)
@ -31,6 +32,46 @@ DEVICES = {
}
def get_last_sessions(page: int, per_page: int) -> Tuple[Generator[Dict[str, Union[str, bool]], None, None], int]:
db_sessions = DB.get_ui_user_sessions(current_user.username, session.get("session_id"))
total_sessions = len(db_sessions)
if total_sessions <= per_page:
per_page = total_sessions
page = 1
elif total_sessions <= (page - 1) * per_page:
page = total_sessions // per_page
def session_generator():
for db_session in db_sessions[(page - 1) * per_page : page * per_page]: # noqa: E203
ua_data = parse(db_session.user_agent)
last_session = {
"current": db_session.id == session.get("session_id"),
"browser": ua_data.get_browser(),
"os": ua_data.get_os(),
"device": ua_data.get_device(),
"ip": db_session.ip,
"creation_date": db_session.creation_date.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"),
"last_activity": db_session.last_activity.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"),
}
for browser, icon in BROWSERS.items():
if browser in last_session["browser"]:
last_session["browser"] = f"<i class='bx {icon} text-primary'></i>&nbsp;{last_session['browser']}"
break
for os, icon in OS.items():
if os in last_session["os"]:
last_session["os"] = f"<i class='bx {icon} text-primary'></i>&nbsp;{last_session['os']}"
break
last_session["device"] = f"<i class='bx {DEVICES.get(last_session['device'], 'bx-mobile')} text-primary'></i>&nbsp;{last_session['device']}"
yield last_session
return session_generator(), total_sessions
@profile.route("/profile", methods=["GET"])
@login_required
def profile_page():
@ -39,35 +80,7 @@ def profile_page():
session["tmp_totp_secret"] = TOTP.generate_totp_secret()
totp_qr_image = TOTP.generate_qrcode(current_user.get_id(), session["tmp_totp_secret"])
last_sessions = []
for db_session in DB.get_ui_user_sessions(current_user.username):
ua_data = parse(db_session.user_agent)
last_session = {
"current": db_session.id == session.get("session_id"),
"browser": ua_data.get_browser(),
"os": ua_data.get_os(),
"device": ua_data.get_device(),
"ip": db_session.ip,
"creation_date": db_session.creation_date.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"),
"last_activity": db_session.last_activity.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"),
}
for browser, icon in BROWSERS.items():
if browser in last_session["browser"]:
last_session["browser"] = f"<i class='bx {icon} text-primary'></i>&nbsp;{last_session['browser']}"
break
for os, icon in OS.items():
if os in last_session["os"]:
last_session["os"] = f"<i class='bx {icon} text-primary'></i>&nbsp;{last_session['os']}"
break
last_session["device"] = f"<i class='bx {DEVICES.get(last_session['device'], 'bx-mobile')} text-primary'></i>&nbsp;{last_session['device']}"
if last_session["current"] and last_sessions:
last_sessions.insert(0, last_session)
continue
last_sessions.append(last_session)
last_sessions, total_sessions = get_last_sessions(1, 3)
return render_template(
"profile.html",
@ -77,9 +90,34 @@ def profile_page():
is_recovery_refreshed=session.pop("totp_refreshed", False),
totp_secret=TOTP.get_totp_pretty_key(session.get("tmp_totp_secret", "")),
last_sessions=last_sessions,
total_sessions=total_sessions,
)
@profile.route("/profile/sessions", methods=["GET"])
@login_required
@cors_required
def get_sessions():
page = request.args.get("page", 1, type=int)
if page < 1:
return Response("Invalid page number", status=400)
session_generator = get_last_sessions(page, 3)[0]
def generate_stream():
yield "["
first = True
for session_data in session_generator:
if not first:
yield ","
first = False
yield jsonify(session_data).get_data(as_text=True)
yield "]"
return Response(stream_with_context(generate_stream()), content_type="application/json")
@profile.route("/profile/totp-refresh", methods=["POST"])
@login_required
def totp_refresh():
@ -246,20 +284,22 @@ def edit_profile():
return redirect(url_for("profile.profile_page"))
@profile.route("/profile/wipe-old-sessions", methods=["POST"])
@profile.route("/profile/wipe-other-sessions", methods=["POST"])
@login_required
def wipe_old_sessions():
if DB.readonly:
return handle_error("Database is in read-only mode", "profile")
verify_data_in_form(data={"password": None}, err_message="Missing current password parameter on /profile/wipe-old-sessions.", redirect_url="profile")
verify_data_in_form(data={"password": None}, err_message="Missing current password parameter on /profile/wipe-other-sessions.", redirect_url="profile")
if not current_user.check_password(request.form["password"]):
return handle_error("The current password is incorrect.", "profile")
DATA["REVOKED_SESSIONS"] = [db_session.id for db_session in DB.get_ui_user_sessions(current_user.username) if db_session.id != session.get("session_id")]
ret = DB.delete_ui_user_old_sessions(current_user.username)
if ret:
return handle_error(f"Couldn't wipe the old sessions in the database: {ret}", "profile")
return handle_error(f"Couldn't wipe the other sessions in the database: {ret}", "profile")
flash("The old sessions have been successfully wiped.")
flash("The other sessions have been successfully wiped.")
return redirect(url_for("profile.profile_page") + "#sessions")

View file

@ -77,14 +77,10 @@ def setup_page():
config = {
"SERVER_NAME": request.form["server_name"],
"USE_UI": "yes",
"USE_TEMPLATE": "ui",
"USE_REVERSE_PROXY": "yes",
"REVERSE_PROXY_HOST": request.form["ui_host"],
"REVERSE_PROXY_URL": request.form["ui_url"] or "/",
"INTERCEPTED_ERROR_CODES": "400 404 405 413 429 500 501 502 503 504",
"ALLOWED_METHODS": "GET|POST|PUT|DELETE",
"MAX_CLIENT_SIZE": "50m",
"KEEP_UPSTREAM_HEADERS": "Content-Security-Policy Strict-Transport-Security X-Frame-Options X-Content-Type-Options Referrer-Policy",
}
if request.form.get("auto_lets_encrypt", "no") == "yes":

View file

@ -1,6 +1,7 @@
from base64 import b64encode
from copy import deepcopy
from datetime import datetime
from functools import wraps
from io import BytesIO
from threading import Thread
from time import sleep, time
@ -368,3 +369,14 @@ def update_service(config, variables, format_configs, server_name, old_server_na
message = f"Deleting {'draft ' if was_draft and is_draft else ''}service {request.form.get('SERVER_NAME', '').split(' ')[0]}"
return message
def cors_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
fetch_mode = request.headers.get("Sec-Fetch-Mode")
if fetch_mode != "cors":
return Response("CORS request required", status=403)
return f(*args, **kwargs)
return decorated_function

View file

@ -0,0 +1,21 @@
/* Initial state for existing cards */
.card-transition {
opacity: 1;
transition: opacity 0.3s ease;
}
/* State for fading out existing cards */
.card-transition.fade-out {
opacity: 0;
}
/* Initial state for placeholder cards (invisible) */
.placeholder-transition {
opacity: 0;
transition: opacity 0.3s ease; /* Duration of the fade-in effect */
}
/* State for fading in placeholders */
.placeholder-transition.fade-in {
opacity: 1;
}

View file

@ -131,173 +131,7 @@ let menu, animate;
* Custom
*/
class News {
constructor() {
this.BASE_URL = "https://www.bunkerweb.io/";
}
init() {
window.addEventListener("load", () => {
if (sessionStorage.getItem("lastRefetch") !== null) {
const storeStamp = sessionStorage.getItem("lastRefetch");
const nowStamp = Math.round(new Date().getTime() / 1000);
if (+nowStamp > storeStamp) {
sessionStorage.removeItem("lastRefetch");
sessionStorage.removeItem("lastNews");
}
}
if (sessionStorage.getItem("lastNews") !== null)
return this.render(JSON.parse(sessionStorage.getItem("lastNews")));
fetch("https://www.bunkerweb.io/api/posts/0/2")
.then((res) => {
return res.json();
})
.then((res) => {
const reverseData = res.data.reverse();
return this.render(reverseData);
})
.catch((e) => {});
});
}
render(lastNews) {
// store for next time if not the case
if (
!sessionStorage.getItem("lastNews") &&
!sessionStorage.getItem("lastRefetch")
) {
sessionStorage.setItem(
"lastRefetch",
Math.round(new Date().getTime() / 1000) + 3600,
);
sessionStorage.setItem("lastNews", JSON.stringify(lastNews));
const newsNumber = lastNews.length;
document.querySelector("#news-pill").insertAdjacentHTML(
"beforeend",
DOMPurify.sanitize(`<span class="badge rounded-pill badge-center-sm bg-danger ms-1_5"
>${newsNumber}</span
>`),
);
document.querySelector("#news-button").insertAdjacentHTML(
"beforeend",
DOMPurify.sanitize(`<span
class="badge-dot-text position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
>
${newsNumber}
<span class="visually-hidden">unread news</span>
</span>`),
);
}
const newsContainer = document.querySelector("[data-news-container]");
const lastItem = lastNews[0];
//remove default message
newsContainer.textContent = "";
document
.querySelector("[data-news-container]")
.insertAdjacentHTML(
"afterbegin",
`<div data-news-row class="row g-6 justify-content-center">`,
);
//render last news
lastNews.forEach((news) => {
//create html card from infos
const cardHTML = this.template(
news.title,
news.slug,
news.photo.url,
news.excerpt,
news.tags,
news.date,
news === lastItem,
);
const BASE_URL = this.BASE_URL;
let cleanHTML = DOMPurify.sanitize(cardHTML);
//add to DOM inside the created div
document
.querySelector("[data-news-row]")
.insertAdjacentHTML("afterbegin", cleanHTML);
document.querySelectorAll(`.blog-click-${news.slug}`).forEach((slug) => {
slug.addEventListener("click", function () {
window.open(
`${BASE_URL}blog/post/${news.slug}?utm_campaign=self&utm_source=ui`,
"_blank",
);
});
});
document.querySelectorAll(".blog-click-tag").forEach((tag) => {
tag.target = "_blank";
});
});
document
.querySelector("[data-news-container]")
.insertAdjacentHTML("beforeend", "</div>");
}
template(title, slug, img, excerpt, tags, date, last) {
//loop on tags to get list
let tagList = "";
tags.forEach((tag) => {
tagList += `<a
role="button"
href="${this.BASE_URL}/blog/tag/${tag.slug}?utm_campaign=self&utm_source=ui"
aria-pressed="true"
class="btn btn-sm btn-outline-primary"
target="_blank"
rel="noopener"
>
<span class="tf-icons bx bx-xs bx-purchase-tag bx-18px me-2"></span
>${tag.name}
</a>
`;
});
const card = `<div class="col-md-11 col-xl-11 ${last ? "" : "mb-1"}">
<div class="card">
<a
href="${this.BASE_URL}blog/post/${slug}?utm_campaign=self&utm_source=ui"
target="_blank"
rel="noopener"
><img class="card-img-top" src="${img}" alt="News image"
/></a>
<div class="card-body">
<h5 class="card-title">
<a
href="${
this.BASE_URL
}blog/post/${slug}?utm_campaign=self&utm_source=ui"
target="_blank"
rel="noopener"
>${title}</a
>
</h5>
<p class="card-text">${excerpt}</p>
<p class="d-flex flex-wrap">${tagList}</p>
<p class="card-text">
<small class="text-muted">Posted on : ${date}</small>
</p>
</div>
</div>
</div>
`;
return card;
}
}
const setNews = new News();
DOMPurify.addHook("afterSanitizeAttributes", function (node) {
// set all elements owning target to target=_blank
if ("target" in node) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener");
}
});
document.addEventListener("DOMContentLoaded", function onContentLoaded() {
setNews.init();
// Generic Copy to Clipboard with Tooltip
$(".copy-to-clipboard").on("click", function () {
const input = $(this).closest(".input-group").find("input")[0];

View file

@ -0,0 +1,29 @@
$(document).ready(function () {
new DataTable("#instances", {
autoFill: false,
});
$("#instance-form").on("submit", function (event) {
event.preventDefault(); // Prevent the default form submission
const form = $(this);
const clickedButton = form.find('button[type="submit"]:focus'); // Find the button that triggered the submit
const action = clickedButton.data("action"); // Get the action from the button
const actionSplit = form.attr("action").split("/");
const instanceHostname = actionSplit[actionSplit.length - 1];
if (
action === "delete" &&
$(`#method-${instanceHostname}`).val() !== "ui"
) {
return;
} else if ($(`#status-${instanceHostname}`).val() !== "Up") {
return;
}
form.attr("action", `${form.attr("action")}/${action}`);
// Now, submit the form with the updated action
form.off("submit").submit();
});
});

View file

@ -1,149 +1,323 @@
$(document).ready(function () {
// Password validation functions
function validatePassword() {
const password = $("#new_password").val();
let isValid = true;
// Validate length
if (password.length >= 8) {
$("#length-check i")
.removeClass("bx-x text-danger")
.addClass("bx-check text-success");
} else {
isValid = false;
$("#length-check i")
.removeClass("bx-check text-success")
.addClass("bx-x text-danger");
}
// Validate uppercase letter
if (/[A-Z]/.test(password)) {
$("#uppercase-check i")
.removeClass("bx-x text-danger")
.addClass("bx-check text-success");
} else {
isValid = false;
$("#uppercase-check i")
.removeClass("bx-check text-success")
.addClass("bx-x text-danger");
}
// Validate number
if (/\d/.test(password)) {
$("#number-check i")
.removeClass("bx-x text-danger")
.addClass("bx-check text-success");
} else {
isValid = false;
$("#number-check i")
.removeClass("bx-check text-success")
.addClass("bx-x text-danger");
}
// Validate special character
if (/[ !"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/.test(password)) {
$("#special-check i")
.removeClass("bx-x text-danger")
.addClass("bx-check text-success");
} else {
isValid = false;
$("#special-check i")
.removeClass("bx-check text-success")
.addClass("bx-x text-danger");
}
isValid = validateCondition(
password.length >= 8,
"#length-check i",
isValid,
);
isValid = validateCondition(
/[A-Z]/.test(password),
"#uppercase-check i",
isValid,
);
isValid = validateCondition(
/\d/.test(password),
"#number-check i",
isValid,
);
isValid = validateCondition(
/[ !"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/.test(password),
"#special-check i",
isValid,
);
return isValid;
}
// Real-time validation as user types
$("#new_password").on("input", function () {
if (validatePassword()) {
$(this).removeClass("is-invalid");
$(this).addClass("is-valid");
function validateCondition(condition, selector, currentValidity) {
if (condition) {
$(selector)
.removeClass("bx-x text-danger")
.addClass("bx-check text-success");
} else {
$(selector)
.removeClass("bx-check text-success")
.addClass("bx-x text-danger");
return false;
}
});
return currentValidity;
}
function matchPassword() {
const newPassword = $("#new_password").val();
const confirmPassword = $("#new_password_confirm").val();
if (newPassword === confirmPassword) {
$("#new_password_confirm").removeClass("is-invalid");
$("#new_password_confirm").addClass("is-valid");
} else {
$("#new_password_confirm").removeClass("is-valid");
$("#new_password_confirm").addClass("is-invalid");
}
const match = newPassword === confirmPassword;
updateValidationState("#new_password_confirm", match);
return match;
}
$("#new_password_confirm").on("input", function () {
if (matchPassword()) {
$(this).removeClass("is-invalid");
$(this).addClass("is-valid");
}
function updateValidationState(selector, isValid) {
$(selector)
.toggleClass("is-valid", isValid)
.toggleClass("is-invalid", !isValid);
}
// Real-time validation as user types
$("#new_password").on("input", function () {
const isValid = validatePassword();
updateValidationState(this, isValid);
});
$("#new_password_confirm").on("input", matchPassword);
// Form submission validation
$("#formPasswordSettings").on("submit", function (e) {
const newPasswordInput = $("#new_password");
const confirmPasswordInput = $("#new_password_confirm");
const isValidPassword = validatePassword();
const isMatchingPassword = matchPassword();
let isValid = true;
// Check if passwords match
if (newPasswordInput.val() !== confirmPasswordInput.val()) {
isValid = false;
confirmPasswordInput.addClass("is-invalid");
} else {
confirmPasswordInput.removeClass("is-invalid");
confirmPasswordInput.addClass("is-valid");
}
// Validate password using real-time checks
if (!validatePassword()) {
isValid = false;
newPasswordInput.addClass("is-invalid");
} else {
newPasswordInput.removeClass("is-invalid");
newPasswordInput.addClass("is-valid");
}
// Prevent form submission if validation fails
if (!isValid) {
if (!isValidPassword || !isMatchingPassword) {
e.preventDefault();
}
});
// Listen for tab change
// Tab change handling
$('button[data-bs-toggle="tab"]').on("shown.bs.tab", function (e) {
// Get the target tab's ID (data-bs-target without the '#')
var target = $(e.target)
.data("bs-target")
.substring(1)
.replace("navs-pills-", "");
if (target === "profile") {
if (window.location.hash) {
history.pushState(
"",
document.title,
window.location.pathname + window.location.search,
);
}
return;
}
// Update the URL fragment
window.location.hash = target;
handleTabChange($(e.target).data("bs-target"));
});
function handleTabChange(targetClass) {
const target = targetClass.substring(1).replace("navs-pills-", "");
const isProfileTab = target === "profile";
if (isProfileTab) {
$("#navs-pills-sessions-pagination").removeClass("show active");
} else {
setTimeout(() => {
$("#navs-pills-sessions-pagination").addClass("show active");
}, 200);
}
if (isProfileTab && window.location.hash) {
history.pushState(
"",
document.title,
window.location.pathname + window.location.search,
);
} else {
window.location.hash = target;
}
}
// On page load, activate the tab based on the URL fragment
var hash = window.location.hash;
const hash = window.location.hash;
if (hash) {
var targetTab = $(
const targetTab = $(
`button[data-bs-target="#navs-pills-${hash.substring(1)}"]`,
);
if (targetTab.length) {
targetTab.tab("show");
}
}
// Pagination and session content handling
const totalPages = $(".page-item").length - 2;
const currentCardClasses = "border-primary border-1 position-relative";
const currentCardHeaderClasses = "bg-primary text-white";
const currentCardIcon = "bx-star text-warning";
const currentItemsClasses = "text-primary";
const otherCardClasses = "border-secondary";
const otherCardHeaderClasses = "bg-secondary";
const otherCardIcon = "bx-history text-white";
const otherItemsClasses = "text-secondary";
let clickLock = false;
$(".page-item").on("click", function () {
if (clickLock) return;
clickLock = true;
const currentPage = parseInt($("#sessions-current-page").text().trim());
let page = $(this).hasClass("prev")
? currentPage - 1
: $(this).hasClass("next")
? currentPage + 1
: parseInt($(this).text().trim());
if (page === currentPage || page < 1 || page > totalPages) {
clickLock = false;
return;
}
updatePagination(page, currentPage);
setPlaceholders(3);
setTimeout(() => {
fadeInPlaceholders();
loadSessionData(page);
}, 50);
});
function updatePagination(newPage, oldPage) {
$("#sessions-current-page").text(newPage);
$(`.page-item[data-page=${oldPage}]`).removeClass("active");
$(`.page-item[data-page=${newPage}]`).addClass("active");
$(".page-item.prev").toggleClass("disabled", newPage === 1);
$(".page-item.next").toggleClass("disabled", newPage === totalPages);
}
function setPlaceholders(numPlaceholders) {
const placeholders = Array.from(
{ length: numPlaceholders },
(_, i) => `
<div id="session-placeholder-${i}" class="card border-secondary shadow-sm mb-4 placeholder-transition">
<div class="card-header bg-secondary d-flex justify-content-between align-items-center mb-2 p-3 placeholder-glow">
<div class="d-flex align-items-center placeholder-glow">
<span class="placeholder col-2"></span>
<h5 class="mb-0 text-white ms-2">Session</h5>
</div>
<span class="placeholder col-2"></span>
</div>
<div class="card-body">
<div class="list-group list-group-flush placeholder-glow">
${generatePlaceholderItems()}
</div>
</div>
</div>
`,
).join("");
$("#sessions-page-content").html(placeholders);
}
function generatePlaceholderItems() {
const items = [
"bx-window-alt",
"Browser",
"bx-layer",
"Operating System",
"bx-devices",
"Device",
"bx-network-chart",
"IP Address",
"bx-time",
"Creation date",
"bx-time",
"Last Activity",
];
return items
.map((icon, i) =>
i % 2 === 0
? `
<div class="list-group-item d-flex align-items-center">
<strong><i class="bx ${icon} text-secondary"></i> ${
items[i + 1]
}:</strong>
&nbsp;<span class="placeholder col-4"></span>
</div>
`
: "",
)
.join("");
}
function fadeInPlaceholders() {
$(".placeholder-transition").addClass("fade-in");
}
function loadSessionData(page) {
const url = `${window.location.pathname}/sessions?page=${page}`;
fetch(url)
.then((response) => {
// Check if the response is OK (status code 200-299)
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json(); // Parse the JSON data from the response
})
.then((data) => {
updateSessionContent(data); // Update the session content with the data
removeExtraPlaceholders(data.length); // Remove any extra placeholders
clickLock = false; // Unlock the clickLock variable
})
.catch((error) => {
handleError(); // Handle any errors that occurred during the fetch
clickLock = false; // Ensure clickLock is unlocked even in case of error
console.error("Fetch error:", error); // Log the error to the console
});
}
function updateSessionContent(sessions) {
sessions.forEach((session, index) => {
const content = generateSessionContent(session, index);
const placeholder = $(`#session-placeholder-${index}`);
placeholder
.html(content)
.removeClass("placeholder-transition fade-in")
.addClass("card-transition")
.toggleClass(currentCardClasses, session.current)
.toggleClass(otherCardClasses, !session.current);
});
}
function generateSessionContent(session, index) {
const items = [
["bx-window-alt", "Browser", session.browser],
["bx-layer", "Operating System", session.os],
["bx-devices", "Device", session.device],
[
"bx-network-chart",
"IP Address",
`<input id="ip-${index}" class="form-control" type="password" autocomplete="off" value="${session.ip}" readonly />`,
],
["bx-time", "Creation date", session.creation_date],
["bx-time", "Last Activity", session.last_activity],
];
return `
<div class="card-header ${
session.current ? currentCardHeaderClasses : otherCardHeaderClasses
}
d-flex justify-content-between align-items-center mb-2 p-3">
<div class="d-flex align-items-center">
<i class="bx ${
session.current ? currentCardIcon : otherCardIcon
}"></i>
<h5 class="mb-0 text-white ms-2">Session</h5>
</div>
${
session.current
? '<span class="badge bg-bw-green text-white fs-6"><i class="bx bx-user-check"></i> Current Session</span>'
: ""
}
</div>
<div class="card-body">
<div class="list-group list-group-flush">
${generateSessionItems(items, session)}
</div>
</div>
`;
}
function generateSessionItems(items, session) {
return items
.map(
([icon, label, value]) => `
<div class="list-group-item d-flex align-items-center">
<strong><i class="bx ${icon} ${
session.current ? currentItemsClasses : otherItemsClasses
}"></i> ${label}:</strong>
&nbsp;${value}
</div>
`,
)
.join("");
}
function removeExtraPlaceholders(sessionCount) {
$(`#sessions-page-content .card:gt(${sessionCount - 1})`).remove();
}
function handleError() {
$("#session-page-content").html(
"<p>Error loading data. Please try again.</p>",
);
}
});

View file

@ -0,0 +1,167 @@
class News {
constructor() {
this.BASE_URL = "https://www.bunkerweb.io/";
}
init() {
window.addEventListener("load", () => {
if (sessionStorage.getItem("lastRefetch") !== null) {
const storeStamp = sessionStorage.getItem("lastRefetch");
const nowStamp = Math.round(new Date().getTime() / 1000);
if (+nowStamp > storeStamp) {
sessionStorage.removeItem("lastRefetch");
sessionStorage.removeItem("lastNews");
}
}
if (sessionStorage.getItem("lastNews") !== null)
return this.render(JSON.parse(sessionStorage.getItem("lastNews")));
fetch("https://www.bunkerweb.io/api/posts/0/2")
.then((res) => {
return res.json();
})
.then((res) => {
const reverseData = res.data.reverse();
return this.render(reverseData);
})
.catch((e) => {});
});
}
render(lastNews) {
// store for next time if not the case
if (
!sessionStorage.getItem("lastNews") &&
!sessionStorage.getItem("lastRefetch")
) {
sessionStorage.setItem(
"lastRefetch",
Math.round(new Date().getTime() / 1000) + 3600,
);
sessionStorage.setItem("lastNews", JSON.stringify(lastNews));
const newsNumber = lastNews.length;
document.querySelector("#news-pill").insertAdjacentHTML(
"beforeend",
DOMPurify.sanitize(`<span class="badge rounded-pill badge-center-sm bg-danger ms-1_5"
>${newsNumber}</span
>`),
);
document.querySelector("#news-button").insertAdjacentHTML(
"beforeend",
DOMPurify.sanitize(`<span
class="badge-dot-text position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
>
${newsNumber}
<span class="visually-hidden">unread news</span>
</span>`),
);
}
const newsContainer = document.querySelector("[data-news-container]");
const lastItem = lastNews[0];
//remove default message
newsContainer.textContent = "";
document
.querySelector("[data-news-container]")
.insertAdjacentHTML(
"afterbegin",
`<div data-news-row class="row g-6 justify-content-center">`,
);
//render last news
lastNews.forEach((news) => {
//create html card from infos
const cardHTML = this.template(
news.title,
news.slug,
news.photo.url,
news.excerpt,
news.tags,
news.date,
news === lastItem,
);
const BASE_URL = this.BASE_URL;
let cleanHTML = DOMPurify.sanitize(cardHTML);
//add to DOM inside the created div
document
.querySelector("[data-news-row]")
.insertAdjacentHTML("afterbegin", cleanHTML);
document.querySelectorAll(`.blog-click-${news.slug}`).forEach((slug) => {
slug.addEventListener("click", function () {
window.open(
`${BASE_URL}blog/post/${news.slug}?utm_campaign=self&utm_source=ui`,
"_blank",
);
});
});
document.querySelectorAll(".blog-click-tag").forEach((tag) => {
tag.target = "_blank";
});
});
document
.querySelector("[data-news-container]")
.insertAdjacentHTML("beforeend", "</div>");
}
template(title, slug, img, excerpt, tags, date, last) {
//loop on tags to get list
let tagList = "";
tags.forEach((tag) => {
tagList += `<a
role="button"
href="${this.BASE_URL}/blog/tag/${tag.slug}?utm_campaign=self&utm_source=ui"
aria-pressed="true"
class="btn btn-sm btn-outline-primary"
target="_blank"
rel="noopener"
>
<span class="tf-icons bx bx-xs bx-purchase-tag bx-18px me-2"></span
>${tag.name}
</a>
`;
});
const card = `<div class="col-md-11 col-xl-11 ${last ? "" : "mb-1"}">
<div class="card">
<a
href="${this.BASE_URL}blog/post/${slug}?utm_campaign=self&utm_source=ui"
target="_blank"
rel="noopener"
><img class="card-img-top" src="${img}" alt="News image"
/></a>
<div class="card-body">
<h5 class="card-title">
<a
href="${
this.BASE_URL
}blog/post/${slug}?utm_campaign=self&utm_source=ui"
target="_blank"
rel="noopener"
>${title}</a
>
</h5>
<p class="card-text">${excerpt}</p>
<p class="d-flex flex-wrap">${tagList}</p>
<p class="card-text">
<small class="text-muted">Posted on : ${date}</small>
</p>
</div>
</div>
</div>
`;
return card;
}
}
const setNews = new News();
DOMPurify.addHook("afterSanitizeAttributes", function (node) {
// set all elements owning target to target=_blank
if ("target" in node) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener");
}
});
document.addEventListener("DOMContentLoaded", function onContentLoaded() {
setNews.init();
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -34,6 +34,9 @@
<link rel="stylesheet"
href="libs/apexcharts/apexcharts.css"
nonce="{{ style_nonce }}" />
<link rel="stylesheet"
href="libs/datatables/datatables.min.css"
nonce="{{ style_nonce }}" />
<!-- Page CSS -->
<!-- Page -->
{% if current_endpoint == "login" or current_endpoint == "totp" %}
@ -42,6 +45,10 @@
<link rel="stylesheet"
href="css/pages/loading.css"
nonce="{{ style_nonce }}" />
{% elif current_endpoint == "profile" %}
<link rel="stylesheet"
href="css/pages/profile.css"
nonce="{{ style_nonce }}" />
{% endif %}
<!-- Helpers -->
<script src="js/helpers.js" nonce="{{ script_nonce }}"></script>
@ -64,15 +71,21 @@
<script src="libs/hammer/hammer.min.js" nonce="{{ script_nonce }}"></script>
<script src="libs/masonry/masonry.pkgd.min.js" nonce="{{ script_nonce }}"></script>
<script src="libs/purify/purify.min.js" nonce="{{ script_nonce }}"></script>
<script src="libs/datatables/datatables.min.js" nonce="{{ script_nonce }}"></script>
<!-- Core JS -->
<script src="js/menu.js" nonce="{{ script_nonce }}"></script>
<script src="libs/apexcharts/apexcharts.min.js" nonce="{{ script_nonce }}"></script>
<!-- Main JS -->
<script src="js/main.js" nonce="{{ script_nonce }}"></script>
{% if current_endpoint != "login" and current_endpoint != "totp" %}
<script async defer src="js/sidebar.js" nonce="{{ script_nonce }}"></script>
{% endif %}
<script src="js/dashboards-analytics.js" nonce="{{ script_nonce }}"></script>
<!-- Page JS -->
{% if current_endpoint == "profile" %}
<script src="js/pages/profile.js" nonce="{{ script_nonce }}"></script>
{% elif current_endpoint == "instances" %}
<script src="js/pages/instances.js" nonce="{{ script_nonce }}"></script>
{% endif %}
<script async defer src="js/buttons.js"></script>
</body>

View file

@ -0,0 +1,74 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 mh-100">
<table id="instances" class="table w-100">
<thead>
<tr>
<th>Hostname</th>
<th>Name</th>
<th>Method</th>
<th>Health</th>
<th>Type</th>
<th>Creation date</th>
<th>Last seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for instance in instances %}
<tr>
<td>{{ instance.hostname }}</td>
<td>{{ instance.name }}</td>
<td id="method-{{ instance.hostname }}">{{ instance.method }}</td>
<td>
{% if instance.status == "up" %}
<span id="status-{{ instance.hostname }}"
class="badge rounded-pill bg-label-success">Up</span>
{% elif instance.status == "down" %}
<span id="status-{{ instance.hostname }}"
class="badge rounded-pill bg-label-danger">Down</span>
{% else %}
<span id="status-{{ instance.hostname }}"
class="badge rounded-pill bg-label-warning">Loading</span>
{% endif %}
</td>
<td>{{ instance.type }}</td>
<td>{{ instance.creation_date.astimezone().strftime("%Y-%m-%d at %H:%M:%S %Z") }}</td>
<td>{{ instance.last_seen.astimezone().strftime("%Y-%m-%d at %H:%M:%S %Z") }}</td>
<td>
<form id="instance-form" action="{{ url_for('instances') }}/{{ instance.hostname }}" method="POST">
<input type="hidden"
name="csrf_token"
value="{{ csrf_token() }}" />
{% if instance.status != "up" %}
{% set disabled = "disabled" %}
{% else %}
{% set disabled = "" %}
{% endif %}
{% if instance.method == "ui" %}
{% set can_delete = "" %}
{% else %}
{% set can_delete = "disabled" %}
{% endif %}
<button type="submit"
data-action="ping"
class="btn btn-sm btn-outline-primary {{ disabled }}">Ping</button>
<button type="submit"
data-action="reload"
class="btn btn-sm btn-outline-warning {{ disabled }}">Reload</button>
<button type="submit"
data-action="stop"
class="btn btn-sm btn-outline-dark {{ disabled }}">Stop</button>
<button type="submit"
data-action="delete"
class="btn btn-sm btn-outline-danger {{ can_delete }}">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -113,7 +113,7 @@
</li>
<li>
<a class="dropdown-item d-flex align-items-center"
href="{{ url_for("logout") }}">
href="{{ url_for('logout') }}">
<i class="bx bx-power-off bx-md me-2"></i><span>Log Out</span>
</a>
</li>

View file

@ -1,35 +1,70 @@
{% extends "dashboard.html" %}
{% block content %}
{% set profile_url = url_for('profile') %}
{% set total_pages = (total_sessions // 3) + (1 if total_sessions % 3 > 0 else 0) %}
<!-- Content -->
<div class="nav-align-top">
<ul class="nav nav-pills flex-column flex-md-row mb-6">
<li class="nav-item me-0 me-sm-3" role="presentation">
<button type="button"
class="nav-link d-flex align-items-center active"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-pills-profile"
aria-controls="navs-pills-profile"
aria-selected="true">
<i class="tf-icons bx bx-user bx-sm"></i>
&nbsp;
<span class="d-none d-sm-inline">Profile</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link d-flex align-items-center"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-pills-sessions"
aria-controls="navs-pills-sessions">
<i class="tf-icons bx bx-link-alt bx-sm"></i>
&nbsp;
<span class="d-none d-sm-inline">Sessions</span>
</button>
</li>
</ul>
<div class="d-flex row">
<div class="col-md-4">
<ul class="nav nav-pills flex-column flex-md-row mb-6">
<li class="nav-item me-0 me-sm-3" role="presentation">
<button type="button"
class="nav-link d-flex align-items-center active"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-pills-profile"
aria-controls="navs-pills-profile"
aria-selected="true">
<i class="tf-icons bx bx-user bx-sm"></i>
&nbsp;
<span class="d-none d-sm-inline">Profile</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button type="button"
class="nav-link d-flex align-items-center"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-pills-sessions"
aria-controls="navs-pills-sessions">
<i class="tf-icons bx bx-link-alt bx-sm"></i>
&nbsp;
<span class="d-none d-sm-inline">Sessions</span>
</button>
</li>
</ul>
</div>
{% if total_pages > 1 %}
<div class="col-md-8">
<div class="tab-pane fade"
id="navs-pills-sessions-pagination"
role="tabpanel">
<div id="sessions-current-page" class="visually-hidden">1</div>
<ul class="pagination justify-content-center mt-1">
<li class="page-item prev disabled">
<button type="button" id="sessions-page-prev" class="page-link">
<i class="tf-icon bx bx-chevrons-left bx-sm"></i>
</button>
</li>
{% for x in range(1, total_pages + 1) %}
<li class="page-item{% if loop.index == 1 %} active{% endif %}"
data-page="{{ x }}">
<button type="button"
id="sessions-page-{{ x }}"
class="page-link"
{% if loop.index== 1 %}aria-selected="true"{% endif %}>
{{ loop.index }}
</button>
</li>
{% endfor %}
<li class="page-item next">
<button type="button" id="sessions-page-next" class="page-link">
<i class="tf-icon bx bx-chevrons-right bx-sm"></i>
</button>
</li>
</ul>
</div>
</div>
{% endif %}
</div>
<div class="tab-content position-relative">
<div class="tab-pane fade show active"
@ -115,9 +150,11 @@
placeholder="Secret Token"
value="{{ totp_secret }}"
readonly />
<span class="input-group-text cursor-pointer copy-to-clipboard">
<i class="bx bx-copy-alt copy-to-clipboard"></i>
</span>
{% if request.is_secure %}
<span class="input-group-text cursor-pointer copy-to-clipboard">
<i class="bx bx-copy-alt copy-to-clipboard"></i>
</span>
{% endif %}
<span class="input-group-text cursor-pointer">
<i class="bx bx-hide"></i>
</span>
@ -320,7 +357,7 @@
<div class="tab-pane fade" id="navs-pills-sessions" role="tabpanel">
<div class="d-flex row justify-content-center">
<div class="col-md-4">
<form method="POST" action="{{ profile_url }}/wipe-old-sessions">
<form method="POST" action="{{ profile_url }}/wipe-other-sessions">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="card mb-4">
<h5 class="card-header">Sessions</h5>
@ -341,126 +378,94 @@
</div>
</div>
<div class="mt-4 d-flex justify-content-center">
<button type="submit" class="btn btn-danger">Wipe Old Sessions</button>
<button type="submit" class="btn btn-danger">Wipe other sessions</button>
</div>
</div>
</div>
</form>
</div>
<div class="col-md-8">
<div class="tab-content position-relative pt-0 ps-3 pe-3">
{% set total_sessions = last_sessions|length %}
{% set total_pages = (total_sessions // 3) + (1 if total_sessions % 3 > 0 else 0) %}
{% for
batched_sessions in last_sessions|batch(3) %}
<div class="tab-pane fade{% if loop.index == 1 %} show active{% endif %}"
id="navs-pages-{{ loop.index }}"
role="tabpanel">
{% for session in batched_sessions %}
<div id="sessions-page-content"
class="tab-content position-relative pt-0 ps-3 pe-3">
{% for session in last_sessions %}
{% if session["current"] %}
{% set card_classes = "border-primary border-1 position-relative" %}
{% set card_header_classes = "bg-primary text-white" %}
{% set card_icon = "bx-star text-warning" %}
{% set items_classes = "text-primary" %}
{% else %}
{% set card_classes = "border-secondary" %}
{% set card_header_classes = "bg-secondary" %}
{% set card_icon = "bx-history text-white" %}
{% set items_classes = "text-secondary" %}
{% endif %}
<div class="card {{ card_classes }} shadow-sm mb-4 card-transition">
<div class="card-header {{ card_header_classes }} d-flex justify-content-between align-items-center mb-2 p-3">
<div class="d-flex align-items-center">
<i class="bx {{ card_icon }} me-2"></i>
<h5 class="mb-0 text-white">Session</h5>
</div>
{% if session["current"] %}
{% set card_classes = "border-primary border-1 position-relative" %}
{% set card_header_classes = "bg-primary text-white" %}
{% set card_icon = "bx-star text-warning" %}
{% set items_classes = "text-primary" %}
{% else %}
{% set card_classes = "border-secondary" %}
{% set card_header_classes = "bg-secondary" %}
{% set card_icon = "bx-history text-white" %}
{% set items_classes = "text-secondary" %}
<span class="badge bg-bw-green text-white fs-6">
<i class="bx bx-user-check"></i> Current Session
</span>
{% endif %}
<div class="card {{ card_classes }} shadow-sm mb-4">
<div class="card-header {{ card_header_classes }} d-flex justify-content-between align-items-center mb-2 p-3">
<div class="d-flex align-items-center">
<i class="bx {{ card_icon }} me-2"></i>
<h5 class="mb-0 text-white">Session</h5>
</div>
{% if session["current"] %}
<span class="badge bg-bw-green text-white fs-6">
<i class="bx bx-user-check"></i> Current Session
</span>
{% endif %}
</div>
<div class="card-body">
<div class="list-group list-group-flush">
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-window-alt {{ items_classes }}"></i>
Browser:</strong>
&nbsp; {{ session["browser"]|safe }}
</div>
<div class="card-body">
<div class="list-group list-group-flush">
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-window-alt {{ items_classes }}"></i>
Browser:</strong>
&nbsp; {{ session["browser"]|safe }}
</div>
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-layer {{ items_classes }}"></i>
Operating System:</strong>
&nbsp; {{ session["os"]|safe }}
</div>
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-devices {{ items_classes }}"></i>
Device:</strong>
&nbsp; {{ session["device"]|safe }}
</div>
<div class="d-flex list-group-item form-password-toggle align-items-center">
<strong>
<i class="bx bx-network-chart {{ items_classes }}"></i>
IP Address:
</strong>
&nbsp;
<div class="input-group input-group-sm input-group-merge w-auto">
<input id="ip"
class="form-control"
type="password"
autocomplete="off"
value="{{ session['ip'] }}"
readonly />
<span class="input-group-text cursor-pointer">
<i class="bx bx-hide"></i>
</span>
</div>
</div>
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-time {{ items_classes }}"></i>
Creation date:</strong>
&nbsp; {{ session["creation_date"] }}
</div>
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-time {{ items_classes }}"></i>
Last Activity:</strong>
&nbsp; {{ session["last_activity"] }}
</div>
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-layer {{ items_classes }}"></i>
Operating System:</strong>
&nbsp; {{ session["os"]|safe }}
</div>
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-devices {{ items_classes }}"></i>
Device:</strong>
&nbsp; {{ session["device"]|safe }}
</div>
<div class="d-flex list-group-item form-password-toggle align-items-center">
<strong>
<i class="bx bx-network-chart {{ items_classes }}"></i>
IP Address:
</strong>
&nbsp;
<div class="input-group input-group-sm input-group-merge w-auto">
<input id="ip"
class="form-control"
type="password"
autocomplete="off"
value="{{ session['ip'] }}"
readonly />
<span class="input-group-text cursor-pointer">
<i class="bx bx-hide"></i>
</span>
</div>
</div>
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-time {{ items_classes }}"></i>
Creation date:</strong>
&nbsp; {{ session["creation_date"] }}
</div>
<div class="list-group-item d-flex align-items-center">
<strong>
<i class="bx bx-time {{ items_classes }}"></i>
Last Activity:</strong>
&nbsp; {{ session["last_activity"] }}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% if total_pages > 1 %}
<ul class="pagination justify-content-center mt-1">
<li class="page-item prev">
<a class="page-link" href="javascript:void(0);"><i class="tf-icon bx bx-chevrons-left bx-sm"></i></a>
</li>
{% for x in range(1, total_pages + 1) %}
<li class="page-item{% if loop.index == 1 %} active{% endif %}"
role="presentation">
<button type="button"
class="page-link"
role="tab"
data-bs-toggle="tab"
data-bs-target="#navs-pages-{{ loop.index }}"
aria-controls="navs-pages-{{ loop.index }}"
{% if loop.index== 1 %}aria-selected="true"{% endif %}>
{{ loop.index }}
</button>
</li>
{% endfor %}
<li class="page-item next">
<a class="page-link" href="javascript:void(0);"><i class="tf-icon bx bx-chevrons-right bx-sm"></i></a>
</li>
</ul>
{% endif %}
</div>
</div>
</div>

View file

@ -6,7 +6,7 @@
<div class="authentication-inner">
<!-- Register -->
<div class="card px-sm-6 px-0">
<a href="{{ url_for("login") }}">
<a href="{{ url_for('login') }}">
<i class="bx bx-arrow-back me-1"></i>
<span>back to login</span></a>
<div class="card-body">

View file

@ -12,6 +12,10 @@ for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in ((
if deps_path not in sys_path:
sys_path.append(deps_path)
from gevent.monkey import patch_all
patch_all()
from passlib import totp
from common_utils import get_version # type: ignore
@ -29,15 +33,21 @@ LOG_LEVEL = getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "info"))
wsgi_app = "main:app"
proc_name = "bunkerweb-ui"
accesslog = "/var/log/bunkerweb/ui-access.log"
accesslog = join(sep, "var", "log", "bunkerweb", "ui-access.log")
access_log_format = '%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
errorlog = "/var/log/bunkerweb/ui.log"
errorlog = join(sep, "var", "log", "bunkerweb", "ui.log")
reuse_port = True
chdir = join(sep, "usr", "share", "bunkerweb", "ui")
umask = 0x027
worker_tmp_dir = join(sep, "dev", "shm")
tmp_upload_dir = join(sep, "var", "tmp", "bunkerweb", "ui")
secure_scheme_headers = {}
forwarded_allow_ips = "*"
pythonpath = join(sep, "usr", "share", "bunkerweb", "deps", "python")
proxy_allow_ips = "*"
casefold_http_method = True
workers = MAX_WORKERS
worker_class = "gthread"
worker_class = "gevent"
threads = int(getenv("MAX_THREADS", MAX_WORKERS * 2))
max_requests_jitter = min(8, MAX_WORKERS)
graceful_timeout = 30

View file

@ -1,5 +1,6 @@
#!/usr/bin/env python3
from contextlib import suppress
from datetime import datetime, timedelta
from json import dumps
from os import getenv, sep
from os.path import join
@ -12,7 +13,6 @@ for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in ((
if deps_path not in sys_path:
sys_path.append(deps_path)
from datetime import datetime, timedelta
from flask import Flask, Response, flash, jsonify, make_response, redirect, render_template, request, session, url_for
from flask_executor import Executor
from flask_login import current_user, LoginManager, login_required, logout_user
@ -65,14 +65,22 @@ with app.app_context():
app.config["SESSION_COOKIE_NAME"] = "__Host-bw_ui_session"
app.config["SESSION_COOKIE_PATH"] = "/"
app.config["SESSION_COOKIE_SECURE"] = True # Required for __Host- prefix
app.config["SESSION_COOKIE_HTTPONLY"] = True # Recommended for security
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["REMEMBER_COOKIE_NAME"] = "__Host-bw_ui_remember_token"
app.config["REMEMBER_COOKIE_PATH"] = "/"
app.config["REMEMBER_COOKIE_SECURE"] = True
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
app.config["REMEMBER_COOKIE_SAMESITE"] = "Lax"
app.config["WTF_CSRF_SSL_STRICT"] = False
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 86400
app.config["SCRIPT_NONCE"] = ""
app.config["EXECUTOR_MAX_WORKERS"] = 4
principal = Principal()
principal.init_app(app)
@ -304,6 +312,9 @@ def before_request():
elif session["user_agent"] != request.headers.get("User-Agent"):
LOGGER.warning(f"User {current_user.get_id()} tried to access his session with a different User-Agent.")
passed = False
elif session["session_id"] in DATA.get("REVOKED_SESSIONS", []):
LOGGER.warning(f"User {current_user.get_id()} tried to access a revoked session.")
passed = False
if not passed:
return logout_page()
@ -349,7 +360,7 @@ def set_security_headers(response):
# * Referrer-Policy header to prevent leaking of sensitive data
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
if current_user.is_authenticated and "session_id" in session:
if not request.path.startswith(("/css", "/img", "/js", "/fonts", "/libs")) and current_user.is_authenticated and "session_id" in session:
executor.submit(mark_user_access, session["session_id"])
return response

View file

@ -5,7 +5,7 @@ Flask-executor==1.0.0
Flask-Login==0.6.3
Flask-Principal==0.4.0
Flask-WTF==1.2.1
gunicorn[gthread]==23.0.0
gunicorn[gevent]==23.0.0
passlib==1.7.4
python-magic==0.4.27
python_dateutil==2.9.0.post0

View file

@ -71,6 +71,109 @@ flask-wtf==1.2.1 \
--hash=sha256:8bb269eb9bb46b87e7c8233d7e7debdf1f8b74bf90cc1789988c29b37a97b695 \
--hash=sha256:fa6793f2fb7e812e0fe9743b282118e581fb1b6c45d414b8af05e659bd653287
# via -r requirements.in
gevent==24.2.1 \
--hash=sha256:03aa5879acd6b7076f6a2a307410fb1e0d288b84b03cdfd8c74db8b4bc882fc5 \
--hash=sha256:117e5837bc74a1673605fb53f8bfe22feb6e5afa411f524c835b2ddf768db0de \
--hash=sha256:141a2b24ad14f7b9576965c0c84927fc85f824a9bb19f6ec1e61e845d87c9cd8 \
--hash=sha256:14532a67f7cb29fb055a0e9b39f16b88ed22c66b96641df8c04bdc38c26b9ea5 \
--hash=sha256:1dffb395e500613e0452b9503153f8f7ba587c67dd4a85fc7cd7aa7430cb02cc \
--hash=sha256:2955eea9c44c842c626feebf4459c42ce168685aa99594e049d03bedf53c2800 \
--hash=sha256:2ae3a25ecce0a5b0cd0808ab716bfca180230112bb4bc89b46ae0061d62d4afe \
--hash=sha256:2e9ac06f225b696cdedbb22f9e805e2dd87bf82e8fa5e17756f94e88a9d37cf7 \
--hash=sha256:368a277bd9278ddb0fde308e6a43f544222d76ed0c4166e0d9f6b036586819d9 \
--hash=sha256:3adfb96637f44010be8abd1b5e73b5070f851b817a0b182e601202f20fa06533 \
--hash=sha256:3d5325ccfadfd3dcf72ff88a92fb8fc0b56cacc7225f0f4b6dcf186c1a6eeabc \
--hash=sha256:432fc76f680acf7cf188c2ee0f5d3ab73b63c1f03114c7cd8a34cebbe5aa2056 \
--hash=sha256:44098038d5e2749b0784aabb27f1fcbb3f43edebedf64d0af0d26955611be8d6 \
--hash=sha256:5a1df555431f5cd5cc189a6ee3544d24f8c52f2529134685f1e878c4972ab026 \
--hash=sha256:6c47ae7d1174617b3509f5d884935e788f325eb8f1a7efc95d295c68d83cce40 \
--hash=sha256:6f947a9abc1a129858391b3d9334c45041c08a0f23d14333d5b844b6e5c17a07 \
--hash=sha256:782a771424fe74bc7e75c228a1da671578c2ba4ddb2ca09b8f959abdf787331e \
--hash=sha256:7899a38d0ae7e817e99adb217f586d0a4620e315e4de577444ebeeed2c5729be \
--hash=sha256:7b00f8c9065de3ad226f7979154a7b27f3b9151c8055c162332369262fc025d8 \
--hash=sha256:8f4b8e777d39013595a7740b4463e61b1cfe5f462f1b609b28fbc1e4c4ff01e5 \
--hash=sha256:90cbac1ec05b305a1b90ede61ef73126afdeb5a804ae04480d6da12c56378df1 \
--hash=sha256:918cdf8751b24986f915d743225ad6b702f83e1106e08a63b736e3a4c6ead789 \
--hash=sha256:9202f22ef811053077d01f43cc02b4aaf4472792f9fd0f5081b0b05c926cca19 \
--hash=sha256:94138682e68ec197db42ad7442d3cf9b328069c3ad8e4e5022e6b5cd3e7ffae5 \
--hash=sha256:968581d1717bbcf170758580f5f97a2925854943c45a19be4d47299507db2eb7 \
--hash=sha256:9d8d0642c63d453179058abc4143e30718b19a85cbf58c2744c9a63f06a1d388 \
--hash=sha256:a7ceb59986456ce851160867ce4929edaffbd2f069ae25717150199f8e1548b8 \
--hash=sha256:b9913c45d1be52d7a5db0c63977eebb51f68a2d5e6fd922d1d9b5e5fd758cc98 \
--hash=sha256:bde283313daf0b34a8d1bab30325f5cb0f4e11b5869dbe5bc61f8fe09a8f66f3 \
--hash=sha256:bf5b9c72b884c6f0c4ed26ef204ee1f768b9437330422492c319470954bc4cc7 \
--hash=sha256:ca80b121bbec76d7794fcb45e65a7eca660a76cc1a104ed439cdbd7df5f0b060 \
--hash=sha256:cdf66977a976d6a3cfb006afdf825d1482f84f7b81179db33941f2fc9673bb1d \
--hash=sha256:d4faf846ed132fd7ebfbbf4fde588a62d21faa0faa06e6f468b7faa6f436b661 \
--hash=sha256:d7f87c2c02e03d99b95cfa6f7a776409083a9e4d468912e18c7680437b29222c \
--hash=sha256:dd23df885318391856415e20acfd51a985cba6919f0be78ed89f5db9ff3a31cb \
--hash=sha256:f5de3c676e57177b38857f6e3cdfbe8f38d1cd754b63200c0615eaa31f514b4f \
--hash=sha256:f5e8e8d60e18d5f7fd49983f0c4696deeddaf6e608fbab33397671e2fcc6cc91 \
--hash=sha256:f7cac622e11b4253ac4536a654fe221249065d9a69feb6cdcd4d9af3503602e0 \
--hash=sha256:f8a04cf0c5b7139bc6368b461257d4a757ea2fe89b3773e494d235b7dd51119f \
--hash=sha256:f8bb35ce57a63c9a6896c71a285818a3922d8ca05d150fd1fe49a7f57287b836 \
--hash=sha256:fbfdce91239fe306772faab57597186710d5699213f4df099d1612da7320d682
# via gunicorn
greenlet==3.0.3 \
--hash=sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67 \
--hash=sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6 \
--hash=sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257 \
--hash=sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4 \
--hash=sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676 \
--hash=sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61 \
--hash=sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc \
--hash=sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca \
--hash=sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7 \
--hash=sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728 \
--hash=sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305 \
--hash=sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6 \
--hash=sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379 \
--hash=sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414 \
--hash=sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04 \
--hash=sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a \
--hash=sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf \
--hash=sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491 \
--hash=sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559 \
--hash=sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e \
--hash=sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274 \
--hash=sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb \
--hash=sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b \
--hash=sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9 \
--hash=sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b \
--hash=sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be \
--hash=sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506 \
--hash=sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405 \
--hash=sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113 \
--hash=sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f \
--hash=sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5 \
--hash=sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230 \
--hash=sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d \
--hash=sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f \
--hash=sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a \
--hash=sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e \
--hash=sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61 \
--hash=sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6 \
--hash=sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d \
--hash=sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71 \
--hash=sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22 \
--hash=sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2 \
--hash=sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3 \
--hash=sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067 \
--hash=sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc \
--hash=sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881 \
--hash=sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3 \
--hash=sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e \
--hash=sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac \
--hash=sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53 \
--hash=sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0 \
--hash=sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b \
--hash=sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83 \
--hash=sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41 \
--hash=sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c \
--hash=sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf \
--hash=sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da \
--hash=sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33
# via gevent
gunicorn==23.0.0 \
--hash=sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d \
--hash=sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec
@ -258,6 +361,12 @@ regex==2024.7.24 \
--hash=sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66 \
--hash=sha256:fbf8c2f00904eaf63ff37718eb13acf8e178cb940520e47b2f05027f5bb34ce3 \
--hash=sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86
# via gevent
# The following packages are considered to be unsafe in a requirements file:
setuptools==74.0.0 \
--hash=sha256:0274581a0037b638b9fc1c6883cc71c0210865aaa76073f7882376b641b84e8f \
--hash=sha256:a85e96b8be2b906f3e3e789adec6a9323abf79758ecfa3065bd740d81158b11e
# via -r requirements.in
six==1.16.0 \
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
@ -294,3 +403,45 @@ zipp==3.20.1 \
--hash=sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064 \
--hash=sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b
# via importlib-metadata
zope-event==5.0 \
--hash=sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26 \
--hash=sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd
# via gevent
zope-interface==7.0.3 \
--hash=sha256:01e6e58078ad2799130c14a1d34ec89044ada0e1495329d72ee0407b9ae5100d \
--hash=sha256:064ade95cb54c840647205987c7b557f75d2b2f7d1a84bfab4cf81822ef6e7d1 \
--hash=sha256:11fa1382c3efb34abf16becff8cb214b0b2e3144057c90611621f2d186b7e1b7 \
--hash=sha256:1bee1b722077d08721005e8da493ef3adf0b7908e0cd85cc7dc836ac117d6f32 \
--hash=sha256:1eeeb92cb7d95c45e726e3c1afe7707919370addae7ed14f614e22217a536958 \
--hash=sha256:21a207c6b2c58def5011768140861a73f5240f4f39800625072ba84e76c9da0b \
--hash=sha256:2545d6d7aac425d528cd9bf0d9e55fcd47ab7fd15f41a64b1c4bf4c6b24946dc \
--hash=sha256:2c4316a30e216f51acbd9fb318aa5af2e362b716596d82cbb92f9101c8f8d2e7 \
--hash=sha256:35062d93bc49bd9b191331c897a96155ffdad10744ab812485b6bad5b588d7e4 \
--hash=sha256:382d31d1e68877061daaa6499468e9eb38eb7625d4369b1615ac08d3860fe896 \
--hash=sha256:3aa8fcbb0d3c2be1bfd013a0f0acd636f6ed570c287743ae2bbd467ee967154d \
--hash=sha256:3d4b91821305c8d8f6e6207639abcbdaf186db682e521af7855d0bea3047c8ca \
--hash=sha256:3de1d553ce72868b77a7e9d598c9bff6d3816ad2b4cc81c04f9d8914603814f3 \
--hash=sha256:3fcdc76d0cde1c09c37b7c6b0f8beba2d857d8417b055d4f47df9c34ec518bdd \
--hash=sha256:5112c530fa8aa2108a3196b9c2f078f5738c1c37cfc716970edc0df0414acda8 \
--hash=sha256:53d678bb1c3b784edbfb0adeebfeea6bf479f54da082854406a8f295d36f8386 \
--hash=sha256:6195c3c03fef9f87c0dbee0b3b6451df6e056322463cf35bca9a088e564a3c58 \
--hash=sha256:6d04b11ea47c9c369d66340dbe51e9031df2a0de97d68f442305ed7625ad6493 \
--hash=sha256:6dd647fcd765030638577fe6984284e0ebba1a1008244c8a38824be096e37fe3 \
--hash=sha256:799ef7a444aebbad5a145c3b34bff012b54453cddbde3332d47ca07225792ea4 \
--hash=sha256:7d92920416f31786bc1b2f34cc4fc4263a35a407425319572cbf96b51e835cd3 \
--hash=sha256:7e0c151a6c204f3830237c59ee4770cc346868a7a1af6925e5e38650141a7f05 \
--hash=sha256:84f8794bd59ca7d09d8fce43ae1b571be22f52748169d01a13d3ece8394d8b5b \
--hash=sha256:95e5913ec718010dc0e7c215d79a9683b4990e7026828eedfda5268e74e73e11 \
--hash=sha256:9b9369671a20b8d039b8e5a1a33abd12e089e319a3383b4cc0bf5c67bd05fe7b \
--hash=sha256:ab985c566a99cc5f73bc2741d93f1ed24a2cc9da3890144d37b9582965aff996 \
--hash=sha256:af94e429f9d57b36e71ef4e6865182090648aada0cb2d397ae2b3f7fc478493a \
--hash=sha256:c96b3e6b0d4f6ddfec4e947130ec30bd2c7b19db6aa633777e46c8eecf1d6afd \
--hash=sha256:cd2690d4b08ec9eaf47a85914fe513062b20da78d10d6d789a792c0b20307fb1 \
--hash=sha256:d3b7ce6d46fb0e60897d62d1ff370790ce50a57d40a651db91a3dde74f73b738 \
--hash=sha256:d976fa7b5faf5396eb18ce6c132c98e05504b52b60784e3401f4ef0b2e66709b \
--hash=sha256:db6237e8fa91ea4f34d7e2d16d74741187e9105a63bbb5686c61fea04cdbacca \
--hash=sha256:ecd32f30f40bfd8511b17666895831a51b532e93fc106bfa97f366589d3e4e0e \
--hash=sha256:f418c88f09c3ba159b95a9d1cfcdbe58f208443abb1f3109f4b9b12fd60b187c
# via
# zope-event
# zope-interface