mirror of
https://github.com/donaldzou/WGDashboard.git
synced 2024-11-22 15:20:09 +01:00
v2.2beta almost done!!!!!
This commit is contained in:
parent
0d380672f3
commit
9d476af384
13
README.md
13
README.md
@ -20,15 +20,26 @@
|
||||
## 📣 What's New: Version v2.2
|
||||
|
||||
- 🎉 **New Features**
|
||||
- **Add new peers**: Now you can add peers directly on dashboard, it will generate a pair of private key and public key. You can also set its DNS, endpoint allowed IPs. Both can set a default value in the setting page. [❤️ in [#44](https://github.com/donaldzou/wireguard-dashboard/issues/44)]
|
||||
- **QR Code:** You can add the private key in peer setting of your existed peer to create a QR code. Or just create a new one, dashboard will now be able to auto generate a private key and public key ;) Don't worry, all keys will be generated on your machine, and **will delete all key files after they got generated**. [❤️ in [#29](https://github.com/donaldzou/wireguard-dashboard/issues/29)]
|
||||
- **Peer configuration file download:** Same as QR code, you now can download the peer configuration file, so you don't need to manually input all the details on the peer machine! [❤️ in [#40](https://github.com/donaldzou/wireguard-dashboard/issues/40)]
|
||||
- **Search peers**: You can now search peers by their name.
|
||||
- **Autostart on boot:** Added a tutorial on how to start the dashboard to on boot! Please read the [tutorial below](#autostart-wireguard-dashboard-on-boot). [❤️ in [#29](https://github.com/donaldzou/wireguard-dashboard/issues/29)]
|
||||
- **Click to copy**: You can now click and copy all peer's public key and configuration's public key.
|
||||
- And many more...
|
||||
- 🪚 **Bug Fixed**
|
||||
- When there are comments in the wireguard config file, will cause the dashboard to crash.
|
||||
- Used regex to search for config files.
|
||||
- **🧐 Other Changes**
|
||||
- Moved all external CSS and JavaScript file to local hosting (Except Bootstrap Icon, due to large amount of SVG files).
|
||||
- Updated `Flask` from`v1.1.2` to `v2.0.1`, and `Jinja` from `v2.10.1` to `v3.0.1`
|
||||
- Updated Python dependencies
|
||||
- Flask: `v1.1.2 => v2.0.1`
|
||||
- Jinja: `v2.10.1 => v3.0.1`
|
||||
- icmplib: `v2.1.1 => v3.0.1`
|
||||
- Updated CSS/JS dependencies
|
||||
- Bootstrap: `v4.5.3 => v4.6.0`
|
||||
- UI adjustment
|
||||
- Adjusted how peers will display in larger screens, used to be 1 row per peer, now is 3 peers in 1 row.
|
||||
<hr>
|
||||
|
||||
|
||||
|
@ -66,6 +66,23 @@ def is_match(regex, text):
|
||||
return pattern.search(text) is not None
|
||||
|
||||
|
||||
def read_conf_file_interface(config_name):
|
||||
conf_location = wg_conf_path + "/" + config_name + ".conf"
|
||||
f = open(conf_location, 'r')
|
||||
file = f.read().split("\n")
|
||||
data = {}
|
||||
peers_start = 0
|
||||
for i in range(len(file)):
|
||||
if not is_match("#(.*)", file[i]):
|
||||
if len(file[i]) > 0:
|
||||
if file[i] != "[Interface]":
|
||||
tmp = re.split(r'\s*=\s*', file[i], 1)
|
||||
if len(tmp) == 2:
|
||||
data[tmp[0]] = tmp[1]
|
||||
f.close()
|
||||
return data
|
||||
|
||||
|
||||
def read_conf_file(config_name):
|
||||
# Read Configuration File Start
|
||||
conf_location = wg_conf_path + "/" + config_name + ".conf"
|
||||
@ -357,14 +374,23 @@ def checkAllowedIP(public_key, ip, config_name):
|
||||
else:
|
||||
return {'status': 'success'}
|
||||
|
||||
|
||||
def checkIp(ip):
|
||||
return is_match("((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.|$)){4}", ip)
|
||||
|
||||
|
||||
def cleanIp(ip):
|
||||
return ip.replace(' ', '')
|
||||
|
||||
|
||||
def cleanIpWithRange(ip):
|
||||
return cleanIp(ip).split(',')
|
||||
|
||||
|
||||
def checkIpWithRange(ip):
|
||||
return is_match("((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.|\/)){4}(0|8|16|24|32)(,|$)", ip)
|
||||
|
||||
|
||||
def checkAllowedIPs(ip):
|
||||
ip = cleanIpWithRange(ip)
|
||||
for i in ip:
|
||||
@ -372,8 +398,6 @@ def checkAllowedIPs(ip):
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
@app.before_request
|
||||
def auth_req():
|
||||
conf = configparser.ConfigParser(strict=False)
|
||||
@ -473,6 +497,7 @@ def update_acct():
|
||||
config.clear()
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
@app.route('/update_peer_default_config', methods=['POST'])
|
||||
def update_peer_default_config():
|
||||
config = configparser.ConfigParser(strict=False)
|
||||
@ -492,13 +517,11 @@ def update_peer_default_config():
|
||||
# Check Endpoint Allowed IPs
|
||||
ip = request.form['peer_endpoint_allowed_ip']
|
||||
if not checkAllowedIPs(ip):
|
||||
session['message'] = "Peer Endpoint Allowed IPs Format Incorrect. Example: 192.168.1.1/32 or 192.168.1.1/32,192.168.1.2/32"
|
||||
session[
|
||||
'message'] = "Peer Endpoint Allowed IPs Format Incorrect. Example: 192.168.1.1/32 or 192.168.1.1/32,192.168.1.2/32"
|
||||
session['message_status'] = "danger"
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
|
||||
|
||||
|
||||
config.set("Peers", "peer_endpoint_allowed_ip", ','.join(cleanIpWithRange(ip)))
|
||||
config.set("Peers", "peer_global_DNS", request.form['peer_global_DNS'])
|
||||
try:
|
||||
@ -679,6 +702,8 @@ def conf(config_name):
|
||||
|
||||
@app.route('/get_config/<config_name>', methods=['GET'])
|
||||
def get_conf(config_name):
|
||||
config_interface = read_conf_file_interface(config_name)
|
||||
|
||||
search = request.args.get('search')
|
||||
if len(search) == 0: search = ""
|
||||
search = urllib.parse.unquote(search)
|
||||
@ -693,12 +718,14 @@ def get_conf(config_name):
|
||||
"public_key": get_conf_pub_key(config_name),
|
||||
"listen_port": get_conf_listen_port(config_name),
|
||||
"running_peer": get_conf_running_peer_number(config_name),
|
||||
"conf_address": config_interface['Address']
|
||||
}
|
||||
if conf_data['status'] == "stopped":
|
||||
conf_data['checked'] = "nope"
|
||||
else:
|
||||
conf_data['checked'] = "checked"
|
||||
return render_template('get_conf.html', conf_data=conf_data, wg_ip=wg_ip, sort_tag=sort, dashboard_refresh_interval=int(config.get("Server", "dashboard_refresh_interval")))
|
||||
return render_template('get_conf.html', conf_data=conf_data, wg_ip=wg_ip, sort_tag=sort,
|
||||
dashboard_refresh_interval=int(config.get("Server", "dashboard_refresh_interval")))
|
||||
|
||||
|
||||
@app.route('/switch/<config_name>', methods=['GET'])
|
||||
@ -731,6 +758,9 @@ def add_peer(config_name):
|
||||
endpoint_allowed_ip = data['endpoint_allowed_ip']
|
||||
DNS = data['DNS']
|
||||
keys = get_conf_peer_key(config_name)
|
||||
if len(public_key) == 0 or len(DNS) == 0 or len(allowed_ips) == 0 or len(endpoint_allowed_ip) == 0:
|
||||
return "Please fill in all required box."
|
||||
|
||||
if type(keys) != list:
|
||||
return config_name + " is not running."
|
||||
if public_key in keys:
|
||||
@ -750,7 +780,8 @@ def add_peer(config_name):
|
||||
stderr=subprocess.STDOUT)
|
||||
status = subprocess.check_output("wg-quick save " + config_name, shell=True, stderr=subprocess.STDOUT)
|
||||
get_all_peers_data(config_name)
|
||||
db.update({"name": data['name'], "private_key": data['private_key'], "DNS": data['DNS'], "endpoint_allowed_ip": endpoint_allowed_ip},
|
||||
db.update({"name": data['name'], "private_key": data['private_key'], "DNS": data['DNS'],
|
||||
"endpoint_allowed_ip": endpoint_allowed_ip},
|
||||
peers.id == public_key)
|
||||
db.close()
|
||||
return "true"
|
||||
@ -804,7 +835,6 @@ def save_peer_setting(config_name):
|
||||
return jsonify(check_key)
|
||||
if check_ip['status'] == "failed":
|
||||
return jsonify(check_ip)
|
||||
|
||||
try:
|
||||
if allowed_ip == "":
|
||||
allowed_ip = '""'
|
||||
@ -815,16 +845,13 @@ def save_peer_setting(config_name):
|
||||
if change_ip.decode("UTF-8") != "":
|
||||
return jsonify({"status": "failed", "msg": change_ip.decode("UTF-8")})
|
||||
|
||||
|
||||
db.update({"name": name, "private_key": private_key, "DNS": DNS, "endpoint_allowed_ip":endpoint_allowed_ip}, peers.id == id)
|
||||
db.update(
|
||||
{"name": name, "private_key": private_key, "DNS": DNS, "endpoint_allowed_ip": endpoint_allowed_ip},
|
||||
peers.id == id)
|
||||
db.close()
|
||||
return jsonify({"status": "success", "msg": ""})
|
||||
except subprocess.CalledProcessError as exc:
|
||||
return jsonify({"status": "failed", "msg": str(exc.output.decode("UTF-8").strip())})
|
||||
|
||||
|
||||
|
||||
|
||||
else:
|
||||
return jsonify({"status": "failed", "msg": "This peer does not exist."})
|
||||
|
||||
@ -881,7 +908,6 @@ def download(config_name):
|
||||
private_key = peer['private_key']
|
||||
allowed_ip = peer['allowed_ip']
|
||||
DNS = peer['DNS']
|
||||
|
||||
filename = peer['name']
|
||||
if len(filename) == 0:
|
||||
filename = "Untitled_Peers"
|
||||
@ -922,7 +948,6 @@ def init_dashboard():
|
||||
config['Account']['username'] = 'admin'
|
||||
if "password" not in config['Account']:
|
||||
config['Account']['password'] = '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
|
||||
|
||||
if "Server" not in config:
|
||||
config['Server'] = {}
|
||||
if 'wg_conf_path' not in config['Server']:
|
||||
@ -939,7 +964,6 @@ def init_dashboard():
|
||||
config['Server']['dashboard_refresh_interval'] = '15000'
|
||||
if 'dashboard_sort' not in config['Server']:
|
||||
config['Server']['dashboard_sort'] = 'status'
|
||||
|
||||
if "Peers" not in config:
|
||||
config['Peers'] = {}
|
||||
if 'peer_global_DNS' not in config['Peers']:
|
||||
|
@ -1,5 +1,5 @@
|
||||
Flask==2.0.1
|
||||
tinydb==4.3.0
|
||||
ifcfg==0.21
|
||||
icmplib==2.1.1
|
||||
icmplib==3.0.1
|
||||
flask-qrcode==3.0.0
|
@ -58,8 +58,17 @@ $("#re_generate_key").click(function (){
|
||||
})
|
||||
|
||||
$("#save_peer").click(function(){
|
||||
if ($("#allowed_ips") !== "" && $("#public_key") !== ""){
|
||||
$(this).attr("disabled","disabled")
|
||||
$(this).html("Saving...")
|
||||
|
||||
if ($("#allowed_ips").val() !== "" && $("#public_key").val() !== "" && $("#new_add_DNS").val() !== "" && $("#new_add_endpoint_allowed_ip").val() != ""){
|
||||
var conf = $(this).attr('conf_id')
|
||||
var data_list = [$("#private_key"), $("#allowed_ips"), $("#new_add_name"), $("#new_add_DNS"), $("#new_add_endpoint_allowed_ip")]
|
||||
for (var i = 0; i < data_list.length; i++){
|
||||
data_list[i].attr("disabled", "disabled")
|
||||
}
|
||||
|
||||
|
||||
$.ajax({
|
||||
method: "POST",
|
||||
url: "/add_peer/"+conf,
|
||||
@ -84,6 +93,9 @@ $("#save_peer").click(function(){
|
||||
}
|
||||
}
|
||||
})
|
||||
}else{
|
||||
$("#add_peer_alert").html("Please fill in all required box.");
|
||||
$("#add_peer_alert").removeClass("d-none");
|
||||
}
|
||||
})
|
||||
var qrcodeModal = new bootstrap.Modal(document.getElementById('qrcode_modal'), {
|
||||
@ -108,6 +120,8 @@ $("body").on("click", ".btn-delete-peer", function(){
|
||||
})
|
||||
|
||||
$("#delete_peer").click(function(){
|
||||
$(this).attr("disabled","disabled")
|
||||
$(this).html("Deleting...")
|
||||
var peer_id = $(this).attr("peer_id");
|
||||
var config = $(this).attr("conf_id");
|
||||
$.ajax({
|
||||
@ -127,6 +141,8 @@ $("#delete_peer").click(function(){
|
||||
load_data($('#search_peer_textbox').val());
|
||||
$('#alertToast').toast('show');
|
||||
$('#alertToast .toast-body').html("Peer deleted!");
|
||||
$("#delete_peer").removeAttr("disabled")
|
||||
$("#delete_peer").html("Delete")
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -189,9 +205,16 @@ $("#peer_private_key_textbox").change(function(){
|
||||
$("#save_peer_setting").click(function (){
|
||||
$(this).attr("disabled","disabled")
|
||||
$(this).html("Saving...")
|
||||
if ($("#peer_DNS_textbox").val() !== "" && $("#peer_allowed_ip_textbox").val() !== ""){
|
||||
if ($("#peer_DNS_textbox").val() !== "" &&
|
||||
$("#peer_allowed_ip_textbox").val() !== "" &&
|
||||
$("#peer_endpoint_allowed_ips").val() != ""
|
||||
){
|
||||
var peer_id = $(this).attr("peer_id");
|
||||
var conf_id = $(this).attr("conf_id");
|
||||
var data_list = [$("#peer_name_textbox"), $("#peer_DNS_textbox"), $("#peer_private_key_textbox"), $("#peer_allowed_ip_textbox"), $("#peer_endpoint_allowed_ips")]
|
||||
for (var i = 0; i < data_list.length; i++){
|
||||
data_list[i].attr("disabled", "disabled")
|
||||
}
|
||||
$.ajax({
|
||||
method: "POST",
|
||||
url: "/save_peer_setting/"+conf_id,
|
||||
@ -218,8 +241,14 @@ $("#save_peer_setting").click(function (){
|
||||
}
|
||||
$("#save_peer_setting").removeAttr("disabled")
|
||||
$("#save_peer_setting").html("Save")
|
||||
for (var i = 0; i < data_list.length; i++){
|
||||
data_list[i].removeAttr("disabled")
|
||||
}
|
||||
}
|
||||
})
|
||||
}else{
|
||||
$("#setting_peer_alert").html("Please fill in all required box.");
|
||||
$("#setting_peer_alert").removeClass("d-none");
|
||||
}
|
||||
|
||||
|
||||
@ -288,7 +317,16 @@ function copyToClipboard(element) {
|
||||
$temp.remove();
|
||||
}
|
||||
|
||||
|
||||
// $(".key").mouseenter(function(){
|
||||
//
|
||||
// })
|
||||
$("body").on("click", ".update_interval", function(){
|
||||
$.ajax({
|
||||
method:"POST",
|
||||
data: "interval="+$(this).attr("refresh-interval"),
|
||||
url: "/update_dashboard_refresh_interval",
|
||||
success: function (res){
|
||||
location.reload()
|
||||
}
|
||||
})
|
||||
});
|
||||
$("body").on("click", ".refresh", function (){
|
||||
load_data($('#search_peer_textbox').val());
|
||||
});
|
||||
|
@ -69,14 +69,14 @@
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="form-group">
|
||||
<label for="new_add_DNS">DNS</label>
|
||||
<label for="new_add_DNS">DNS <code>(Required)</code></label>
|
||||
<input type="text" class="form-control" id="new_add_DNS" value="{{ DNS }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<div class="form-group">
|
||||
<label for="new_add_endpoint_allowed_ip">Endpoint Allowed IPs</label>
|
||||
<label for="new_add_endpoint_allowed_ip">Endpoint Allowed IPs <code>(Required)</code></label>
|
||||
<input type="text" class="form-control" id="new_add_endpoint_allowed_ip" value="{{ endpoint_allowed_ip }}">
|
||||
</div>
|
||||
</div>
|
||||
@ -228,7 +228,6 @@
|
||||
|
||||
function load_data(search){
|
||||
startProgressBar()
|
||||
|
||||
$.ajax({
|
||||
method: "GET",
|
||||
url: "/get_config/"+conf_name+"?search="+encodeURIComponent(search),
|
||||
@ -237,12 +236,10 @@
|
||||
},
|
||||
success: function (response){
|
||||
$("#config_body").html(response);
|
||||
{#$("[refresh-interval={{ dashboard_refresh_interval }}]").addClass("active")#}
|
||||
$("#search_peer_textbox").css("display", "block")
|
||||
if (bar.css("width") !== "0%"){
|
||||
endProgressBar()
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -253,19 +250,7 @@
|
||||
}, {{dashboard_refresh_interval}})
|
||||
});
|
||||
|
||||
$("body").on("click", ".update_interval", function(){
|
||||
$.ajax({
|
||||
method:"POST",
|
||||
data: "interval="+$(this).attr("refresh-interval"),
|
||||
url: "/update_dashboard_refresh_interval",
|
||||
success: function (res){
|
||||
location.reload()
|
||||
}
|
||||
})
|
||||
});
|
||||
$("body").on("click", ".refresh", function (){
|
||||
load_data($('#search_peer_textbox').val());
|
||||
});
|
||||
|
||||
</script>
|
||||
<script src="{{ url_for('static',filename='js/configuration.js') }}"></script>
|
||||
</html>
|
@ -49,6 +49,10 @@
|
||||
<small class="text-muted"><strong>LISTEN PORT</strong></small>
|
||||
<h6 style="text-transform: uppercase;"><samp>{{conf_data['listen_port']}}</samp></h6>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<small class="text-muted"><strong>ADDRESS</strong></small>
|
||||
<h6 style="text-transform: uppercase;"><samp>{{conf_data['conf_address']}}</samp></h6>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="button-div mb-3">
|
||||
@ -66,12 +70,12 @@
|
||||
<div class="col-sm">
|
||||
<div class="form-group">
|
||||
<label><small class="text-muted">Refresh Interval</small></label><br>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-primary btn-group-label refresh"><i class="bi bi-arrow-repeat"></i></button>
|
||||
<button type="button" class="btn btn-outline-primary update_interval {% if dashboard_refresh_interval == 5000 %} {{ "active" }} {% endif %}" refresh-interval="5000">5s</button>
|
||||
<button type="button" class="btn btn-outline-primary update_interval {% if dashboard_refresh_interval == 10000 %} {{ "active" }} {% endif %}" refresh-interval="10000">10s</button>
|
||||
<button type="button" class="btn btn-outline-primary update_interval {% if dashboard_refresh_interval == 30000 %} {{ "active" }} {% endif %}" refresh-interval="30000">30s</button>
|
||||
<button type="button" class="btn btn-outline-primary update_interval {% if dashboard_refresh_interval == 60000 %} {{ "active" }} {% endif %}" refresh-interval="60000">1m</button>
|
||||
<div class="btn-group" role="group" style="width: 100%">
|
||||
<button style="width: 20%" type="button" class="btn btn-outline-primary btn-group-label refresh"><i class="bi bi-arrow-repeat"></i></button>
|
||||
<button style="width: 20%" type="button" class="btn btn-outline-primary update_interval {% if dashboard_refresh_interval == 5000 %} {{ "active" }} {% endif %}" refresh-interval="5000">5s</button>
|
||||
<button style="width: 20%" type="button" class="btn btn-outline-primary update_interval {% if dashboard_refresh_interval == 10000 %} {{ "active" }} {% endif %}" refresh-interval="10000">10s</button>
|
||||
<button style="width: 20%" type="button" class="btn btn-outline-primary update_interval {% if dashboard_refresh_interval == 30000 %} {{ "active" }} {% endif %}" refresh-interval="30000">30s</button>
|
||||
<button style="width: 20%" type="button" class="btn btn-outline-primary update_interval {% if dashboard_refresh_interval == 60000 %} {{ "active" }} {% endif %}" refresh-interval="60000">1m</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -151,7 +155,7 @@
|
||||
<button type="button" class="btn btn-outline-success btn-qrcode-peer btn-control" img_src="{{ qrcode("[Interface]\nPrivateKey = "+i['private_key']+"\nAddress = "+i['allowed_ip']+"\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = "+conf_data['public_key']+"\nAllowedIPs = 0.0.0.0/0\nEndpoint = "+wg_ip+":"+conf_data['listen_port']) }}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="width: 19px;" fill="#28a745"><path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2zM15 19h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2z"/></svg>
|
||||
</button>
|
||||
<a href="/download/{{ conf_data['name'] }}?id={{ i['id']|urlencode }}" type="button" class="btn btn-outline-info btn-download-peer btn-control">
|
||||
<a href="/download/{{ conf_data['name'] }}?id={{ i['id']|urlencode }}" class="btn btn-outline-info btn-download-peer btn-control">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Wireguard Dashboard - {{ title }}</title>
|
||||
<title>Wireguard Dashboard | {{ title }}</title>
|
||||
<link rel="icon" href="{{ url_for('static',filename='img/logo.png') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('static',filename='css/bootstrap.min.css') }}">
|
||||
<link rel= "stylesheet" type= "text/css" href= "{{ url_for('static',filename='css/dashboard.css') }}">
|
||||
|
@ -1,5 +1,9 @@
|
||||
<html>
|
||||
{% with %}
|
||||
{% set title="Home" %}
|
||||
{% include "header.html"%}
|
||||
{% endwith %}
|
||||
|
||||
<body>
|
||||
{% include "navbar.html" %}
|
||||
<div class="container-fluid">
|
||||
|
@ -1,5 +1,8 @@
|
||||
<html>
|
||||
{% with %}
|
||||
{% set title="Settings" %}
|
||||
{% include "header.html"%}
|
||||
{% endwith %}
|
||||
<body>
|
||||
{% include "navbar.html" %}
|
||||
<div class="container-fluid">
|
||||
|
Loading…
Reference in New Issue
Block a user