mirror of
https://github.com/bunkerity/bunkerweb
synced 2026-05-24 09:28:37 +00:00
Finished profile page + Start working on instances page in web UI
This commit is contained in:
parent
202dcfbadd
commit
68212ac0ee
21 changed files with 1305 additions and 543 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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> {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> {last_session['os']}"
|
||||
break
|
||||
|
||||
last_session["device"] = f"<i class='bx {DEVICES.get(last_session['device'], 'bx-mobile')} text-primary'></i> {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> {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> {last_session['os']}"
|
||||
break
|
||||
|
||||
last_session["device"] = f"<i class='bx {DEVICES.get(last_session['device'], 'bx-mobile')} text-primary'></i> {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")
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
src/ui/app/static/css/pages/profile.css
Normal file
21
src/ui/app/static/css/pages/profile.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
29
src/ui/app/static/js/pages/instances.js
Normal file
29
src/ui/app/static/js/pages/instances.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
<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>
|
||||
${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>",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
167
src/ui/app/static/js/sidebar.js
Normal file
167
src/ui/app/static/js/sidebar.js
Normal 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();
|
||||
});
|
||||
44
src/ui/app/static/libs/datatables/datatables.min.css
vendored
Normal file
44
src/ui/app/static/libs/datatables/datatables.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
219
src/ui/app/static/libs/datatables/datatables.min.js
vendored
Normal file
219
src/ui/app/static/libs/datatables/datatables.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
74
src/ui/app/templates/instances.html
Normal file
74
src/ui/app/templates/instances.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
|
||||
<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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
|
||||
<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>
|
||||
{{ 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>
|
||||
{{ 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue