first commit
This commit is contained in:
546
app.py
Normal file
546
app.py
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
from flask import Flask, request, redirect, url_for, render_template_string, flash, Response
|
||||||
|
import pymysql
|
||||||
|
from pymysql.cursors import DictCursor
|
||||||
|
import os
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("SECRET_KEY", "zmenit-na-vlastni-tajne-heslo")
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "localhost")
|
||||||
|
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
|
||||||
|
DB_USER = os.environ.get("DB_USER", "ipam_user")
|
||||||
|
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "ipam")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
return pymysql.connect(
|
||||||
|
host=DB_HOST,
|
||||||
|
port=DB_PORT,
|
||||||
|
user=DB_USER,
|
||||||
|
password=DB_PASSWORD,
|
||||||
|
database=DB_NAME,
|
||||||
|
cursorclass=DictCursor,
|
||||||
|
autocommit=True,
|
||||||
|
charset="utf8mb4"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ipv4(ip_text: str) -> bool:
|
||||||
|
try:
|
||||||
|
ipaddress.IPv4Address(ip_text)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
INDEX_TEMPLATE = """
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>IPplan</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 30px;
|
||||||
|
background: #f7f7f7;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: auto;
|
||||||
|
background: white;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
.btn-export {
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.flash {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.flash.success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.flash.error {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.form-card {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.ip-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.result-info {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
padding: 24px 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.inline-form {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
async function copyToClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch (err) {
|
||||||
|
prompt("Kopírování se nepovedlo automaticky, zkopíruj ručně:", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(ip) {
|
||||||
|
return confirm("Opravdu smazat záznam pro IP " + ip + "?");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% if mode == 'list' %}
|
||||||
|
<div class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>IPplan</h1>
|
||||||
|
<div class="muted">Řazeno podle IP adresy</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<a class="btn btn-export" href="{{ url_for('export_json', q=query or '') }}">Export JSON</a>
|
||||||
|
<a class="btn" href="{{ url_for('add_item') }}">+ Přidat IP</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="{{ url_for('index') }}" class="search-form">
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
placeholder="Hledat v IP, FQDN nebo účelu"
|
||||||
|
value="{{ query or '' }}"
|
||||||
|
>
|
||||||
|
<button class="btn" type="submit">Hledat</button>
|
||||||
|
{% if query %}
|
||||||
|
<a class="btn btn-secondary" href="{{ url_for('index') }}">Zrušit filtr</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="result-info">
|
||||||
|
{% if query %}
|
||||||
|
Nalezeno záznamů: <strong>{{ rows|length }}</strong> pro výraz <strong>{{ query }}</strong>
|
||||||
|
{% else %}
|
||||||
|
Celkem záznamů: <strong>{{ rows|length }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rows %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP adresa</th>
|
||||||
|
<th>FQDN</th>
|
||||||
|
<th>Účel</th>
|
||||||
|
<th>Akce</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="ip-cell">
|
||||||
|
<span>{{ row.ipaddress }}</span>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
type="button"
|
||||||
|
title="Kopírovat IP adresu"
|
||||||
|
aria-label="Kopírovat IP adresu"
|
||||||
|
onclick="copyToClipboard('{{ row.ipaddress }}')"
|
||||||
|
>⧉</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.fqdn or '' }}</td>
|
||||||
|
<td>{{ row.purpose or '' }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a class="btn" href="{{ url_for('edit_item', item_id=row.id) }}">Upravit</a>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('delete_item', item_id=row.id) }}"
|
||||||
|
class="inline-form"
|
||||||
|
onsubmit="return confirmDelete('{{ row.ipaddress }}')"
|
||||||
|
>
|
||||||
|
<button class="btn btn-danger" type="submit">Smazat</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Žádné záznamy nebyly nalezeny.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif mode == 'edit' %}
|
||||||
|
<div class="form-card">
|
||||||
|
<h1>Upravit záznam</h1>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="ipaddress">IP adresa</label>
|
||||||
|
<input type="text" id="ipaddress" name="ipaddress" value="{{ item.ipaddress }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="fqdn">FQDN</label>
|
||||||
|
<input type="text" id="fqdn" name="fqdn" value="{{ item.fqdn or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="purpose">Účel</label>
|
||||||
|
<input type="text" id="purpose" name="purpose" value="{{ item.purpose or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" type="submit">Uložit</button>
|
||||||
|
<a class="btn btn-secondary" href="{{ url_for('index') }}">Zpět</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% elif mode == 'add' %}
|
||||||
|
<div class="form-card">
|
||||||
|
<h1>Přidat záznam</h1>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="ipaddress">IP adresa</label>
|
||||||
|
<input type="text" id="ipaddress" name="ipaddress" value="{{ form_data.ipaddress or '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="fqdn">FQDN</label>
|
||||||
|
<input type="text" id="fqdn" name="fqdn" value="{{ form_data.fqdn or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="purpose">Účel</label>
|
||||||
|
<input type="text" id="purpose" name="purpose" value="{{ form_data.purpose or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" type="submit">Uložit</button>
|
||||||
|
<a class="btn btn-secondary" href="{{ url_for('index') }}">Zpět</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
query = request.args.get("q", "").strip()
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if query:
|
||||||
|
like_value = f"%{query}%"
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, ipaddress, fqdn, purpose
|
||||||
|
FROM iplist
|
||||||
|
WHERE ipaddress LIKE %s
|
||||||
|
OR fqdn LIKE %s
|
||||||
|
OR purpose LIKE %s
|
||||||
|
ORDER BY INET_ATON(ipaddress) ASC
|
||||||
|
""", (like_value, like_value, like_value))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, ipaddress, fqdn, purpose
|
||||||
|
FROM iplist
|
||||||
|
ORDER BY INET_ATON(ipaddress) ASC
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return render_template_string(
|
||||||
|
INDEX_TEMPLATE,
|
||||||
|
mode="list",
|
||||||
|
rows=rows,
|
||||||
|
query=query
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/export/json")
|
||||||
|
def export_json():
|
||||||
|
query = request.args.get("q", "").strip()
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if query:
|
||||||
|
like_value = f"%{query}%"
|
||||||
|
cur.execute("""
|
||||||
|
SELECT ipaddress, fqdn, purpose
|
||||||
|
FROM iplist
|
||||||
|
WHERE ipaddress LIKE %s
|
||||||
|
OR fqdn LIKE %s
|
||||||
|
OR purpose LIKE %s
|
||||||
|
ORDER BY INET_ATON(ipaddress) ASC
|
||||||
|
""", (like_value, like_value, like_value))
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT ipaddress, fqdn, purpose
|
||||||
|
FROM iplist
|
||||||
|
ORDER BY INET_ATON(ipaddress) ASC
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
json_data = json.dumps(rows, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json_data,
|
||||||
|
mimetype="application/json",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": "attachment; filename=iplist.json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/add", methods=["GET", "POST"])
|
||||||
|
def add_item():
|
||||||
|
form_data = {
|
||||||
|
"ipaddress": "",
|
||||||
|
"fqdn": "",
|
||||||
|
"purpose": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form_data["ipaddress"] = request.form.get("ipaddress", "").strip()
|
||||||
|
form_data["fqdn"] = request.form.get("fqdn", "").strip()
|
||||||
|
form_data["purpose"] = request.form.get("purpose", "").strip()
|
||||||
|
|
||||||
|
if not validate_ipv4(form_data["ipaddress"]):
|
||||||
|
flash("Neplatná IPv4 adresa.", "error")
|
||||||
|
return render_template_string(INDEX_TEMPLATE, mode="add", form_data=form_data)
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO iplist (ipaddress, fqdn, purpose)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""", (
|
||||||
|
form_data["ipaddress"],
|
||||||
|
form_data["fqdn"] or None,
|
||||||
|
form_data["purpose"] or None
|
||||||
|
))
|
||||||
|
flash("Záznam byl přidán.", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
except pymysql.err.IntegrityError:
|
||||||
|
flash("Tahle IP adresa už v databázi existuje.", "error")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return render_template_string(INDEX_TEMPLATE, mode="add", form_data=form_data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/edit/<int:item_id>", methods=["GET", "POST"])
|
||||||
|
def edit_item(item_id):
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if request.method == "POST":
|
||||||
|
ipaddress_value = request.form.get("ipaddress", "").strip()
|
||||||
|
fqdn_value = request.form.get("fqdn", "").strip()
|
||||||
|
purpose_value = request.form.get("purpose", "").strip()
|
||||||
|
|
||||||
|
if not validate_ipv4(ipaddress_value):
|
||||||
|
flash("Neplatná IPv4 adresa.", "error")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE iplist
|
||||||
|
SET ipaddress = %s,
|
||||||
|
fqdn = %s,
|
||||||
|
purpose = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (
|
||||||
|
ipaddress_value,
|
||||||
|
fqdn_value or None,
|
||||||
|
purpose_value or None,
|
||||||
|
item_id
|
||||||
|
))
|
||||||
|
flash("Záznam byl uložen.", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
except pymysql.err.IntegrityError:
|
||||||
|
flash("Tahle IP adresa už v databázi existuje.", "error")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, ipaddress, fqdn, purpose
|
||||||
|
FROM iplist
|
||||||
|
WHERE id = %s
|
||||||
|
""", (item_id,))
|
||||||
|
item = cur.fetchone()
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
flash("Záznam nebyl nalezen.", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return render_template_string(INDEX_TEMPLATE, mode="edit", item=item)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/delete/<int:item_id>", methods=["POST"])
|
||||||
|
def delete_item(item_id):
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT ipaddress FROM iplist WHERE id = %s", (item_id,))
|
||||||
|
item = cur.fetchone()
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
flash("Záznam nebyl nalezen.", "error")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
cur.execute("DELETE FROM iplist WHERE id = %s", (item_id,))
|
||||||
|
flash(f"Záznam {item['ipaddress']} byl smazán.", "success")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
18
ippam.service
Normal file
18
ippam.service
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Simple IPAM web app
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/ipplan
|
||||||
|
Environment="DB_HOST=localhost"
|
||||||
|
Environment="DB_PORT=3306"
|
||||||
|
Environment="DB_USER=ipam_user"
|
||||||
|
Environment="DB_PASSWORD=Megakrutoprisneheslo"
|
||||||
|
Environment="DB_NAME=ipam"
|
||||||
|
Environment="SECRET_KEY=nejake-dlouhe-tajne-heslo"
|
||||||
|
ExecStart=/usr/bin/python3 /opt/ipplan/app.py
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user