multi account large update
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name Grepolis Remote Control
|
// @name Grepolis Remote Control
|
||||||
// @namespace http://tampermonkey.net/
|
// @namespace http://tampermonkey.net/
|
||||||
// @version 2.7
|
// @version 2.8
|
||||||
// @description Polls grepo.haunter-pets.top for remote commands and executes them in-game
|
// @description Polls grepo.haunter-pets.top for remote commands and executes them in-game (Multi-Player)
|
||||||
// @author Dimitrios
|
// @author Dimitrios
|
||||||
// @match https://*.grepolis.com/game/*
|
// @match https://*.grepolis.com/game/*
|
||||||
// @grant unsafeWindow
|
// @grant unsafeWindow
|
||||||
@@ -287,7 +287,9 @@
|
|||||||
let captchaActive = false;
|
let captchaActive = false;
|
||||||
|
|
||||||
function reportCaptcha(detected) {
|
function reportCaptcha(detected) {
|
||||||
fetch(`${BASE_URL}/api/captcha/alert`, {
|
const player_id = uw.Game?.player_id;
|
||||||
|
if (!player_id) return;
|
||||||
|
fetch(`${BASE_URL}/api/captcha/alert?player_id=${player_id}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ detected })
|
body: JSON.stringify({ detected })
|
||||||
@@ -447,10 +449,12 @@
|
|||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
async function pollAndExecute() {
|
async function pollAndExecute() {
|
||||||
if (paused) return;
|
if (paused) return;
|
||||||
|
const player_id = uw.Game?.player_id;
|
||||||
|
if (!player_id) return;
|
||||||
|
|
||||||
let cmdData;
|
let cmdData;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BASE_URL}/api/commands/pending`);
|
const res = await fetch(`${BASE_URL}/api/commands/pending?player_id=${player_id}`);
|
||||||
cmdData = await res.json();
|
cmdData = await res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`Poll failed: ${e}`);
|
log(`Poll failed: ${e}`);
|
||||||
@@ -487,7 +491,7 @@
|
|||||||
// Boot
|
// Boot
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
log('Grepolis Remote Control v2.5 loaded');
|
log('Grepolis Remote Control v2.8 loaded');
|
||||||
|
|
||||||
// Start captcha watcher immediately
|
// Start captcha watcher immediately
|
||||||
detectCaptcha();
|
detectCaptcha();
|
||||||
|
|||||||
1
db.py
1
db.py
@@ -62,6 +62,7 @@ def init_db():
|
|||||||
'ALTER TABLE town_state ADD COLUMN x REAL',
|
'ALTER TABLE town_state ADD COLUMN x REAL',
|
||||||
'ALTER TABLE town_state ADD COLUMN y REAL',
|
'ALTER TABLE town_state ADD COLUMN y REAL',
|
||||||
'ALTER TABLE town_state ADD COLUMN sea INTEGER',
|
'ALTER TABLE town_state ADD COLUMN sea INTEGER',
|
||||||
|
'ALTER TABLE commands ADD COLUMN player_id TEXT',
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
c.execute(_col)
|
c.execute(_col)
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ def receive_state():
|
|||||||
# Returns one 'build' AND one 'recruit' command independently,
|
# Returns one 'build' AND one 'recruit' command independently,
|
||||||
# so both queues are served in parallel without blocking each other.
|
# so both queues are served in parallel without blocking each other.
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _fetch_pending_of_type(c, cmd_type):
|
def _fetch_pending_of_type(c, cmd_type, player_id):
|
||||||
row = c.execute('''
|
row = c.execute('''
|
||||||
SELECT * FROM commands
|
SELECT * FROM commands
|
||||||
WHERE status = 'pending' AND type = ?
|
WHERE status = 'pending' AND type = ? AND player_id = ?
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
''', (cmd_type,)).fetchone()
|
''', (cmd_type, player_id)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
c.execute('''
|
c.execute('''
|
||||||
@@ -88,12 +88,16 @@ def _fetch_pending_of_type(c, cmd_type):
|
|||||||
|
|
||||||
@api.route('/api/commands/pending', methods=['GET'])
|
@api.route('/api/commands/pending', methods=['GET'])
|
||||||
def get_pending_command():
|
def get_pending_command():
|
||||||
|
player_id = request.args.get('player_id')
|
||||||
|
if not player_id:
|
||||||
|
return jsonify({'error': 'no player_id provided'}), 400
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
build_cmd = _fetch_pending_of_type(c, 'build')
|
build_cmd = _fetch_pending_of_type(c, 'build', player_id)
|
||||||
recruit_cmd = _fetch_pending_of_type(c, 'recruit')
|
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id)
|
||||||
market_cmd = _fetch_pending_of_type(c, 'market_offer')
|
market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -133,16 +137,22 @@ def command_result(cmd_id):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@api.route('/api/captcha/alert', methods=['POST'])
|
@api.route('/api/captcha/alert', methods=['POST'])
|
||||||
def captcha_alert():
|
def captcha_alert():
|
||||||
|
player_id = request.args.get('player_id')
|
||||||
|
if not player_id:
|
||||||
|
return jsonify({'error': 'no player_id provided'}), 400
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
detected = bool(data.get('detected', False))
|
detected = bool(data.get('detected', False))
|
||||||
|
kv_key = f'captcha_active_{player_id}'
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
INSERT INTO kv_store (key, value, updated_at)
|
INSERT INTO kv_store (key, value, updated_at)
|
||||||
VALUES ('captcha_active', ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON CONFLICT(key) DO UPDATE SET
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
value = excluded.value,
|
value = excluded.value,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
''', ('1' if detected else '0', datetime.utcnow().isoformat()))
|
''', (kv_key, '1' if detected else '0', datetime.utcnow().isoformat()))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ dashboard = Blueprint('dashboard', __name__)
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@dashboard.route('/')
|
@dashboard.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template('dashboard.html')
|
conn = get_db()
|
||||||
|
players = conn.execute('SELECT DISTINCT player, player_id FROM town_state WHERE player IS NOT NULL ORDER BY player ASC').fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render_template('index.html', players=players)
|
||||||
|
|
||||||
|
@dashboard.route('/player/<player_id>')
|
||||||
|
def player_dashboard(player_id):
|
||||||
|
return render_template('dashboard.html', player_id=player_id)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -21,13 +28,15 @@ def index():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@dashboard.route('/dashboard/towns', methods=['GET'])
|
@dashboard.route('/dashboard/towns', methods=['GET'])
|
||||||
def get_towns():
|
def get_towns():
|
||||||
|
player_id = request.args.get('player_id')
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
rows = conn.execute('''
|
rows = conn.execute('''
|
||||||
SELECT town_id, town_name, player, player_id, alliance_id,
|
SELECT town_id, town_name, player, player_id, alliance_id,
|
||||||
world_id, x, y, sea, data, updated_at
|
world_id, x, y, sea, data, updated_at
|
||||||
FROM town_state
|
FROM town_state
|
||||||
|
WHERE player_id = ?
|
||||||
ORDER BY town_name ASC
|
ORDER BY town_name ASC
|
||||||
''').fetchall()
|
''', (player_id, )).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
towns = []
|
towns = []
|
||||||
@@ -74,10 +83,11 @@ def get_towns():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@dashboard.route('/dashboard/client-status', methods=['GET'])
|
@dashboard.route('/dashboard/client-status', methods=['GET'])
|
||||||
def client_status():
|
def client_status():
|
||||||
|
player_id = request.args.get('player_id')
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute('''
|
row = conn.execute('''
|
||||||
SELECT MAX(updated_at) AS last_seen FROM town_state
|
SELECT MAX(updated_at) AS last_seen FROM town_state WHERE player_id = ?
|
||||||
''').fetchone()
|
''', (player_id, )).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
last_seen = row['last_seen'] if row else None
|
last_seen = row['last_seen'] if row else None
|
||||||
@@ -100,9 +110,10 @@ def client_status():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@dashboard.route('/dashboard/captcha-status', methods=['GET'])
|
@dashboard.route('/dashboard/captcha-status', methods=['GET'])
|
||||||
def captcha_status():
|
def captcha_status():
|
||||||
|
player_id = request.args.get('player_id')
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT value FROM kv_store WHERE key = 'captcha_active'"
|
"SELECT value FROM kv_store WHERE key = ?", (f'captcha_active_{player_id}', )
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
active = bool(row and row['value'] == '1')
|
active = bool(row and row['value'] == '1')
|
||||||
@@ -115,13 +126,15 @@ def captcha_status():
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@dashboard.route('/dashboard/commands', methods=['GET'])
|
@dashboard.route('/dashboard/commands', methods=['GET'])
|
||||||
def get_commands():
|
def get_commands():
|
||||||
|
player_id = request.args.get('player_id')
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
rows = conn.execute('''
|
rows = conn.execute('''
|
||||||
SELECT id, town_id, town_name, type, payload, status, result_msg, created_at, updated_at
|
SELECT id, town_id, town_name, type, payload, status, result_msg, created_at, updated_at
|
||||||
FROM commands
|
FROM commands
|
||||||
|
WHERE player_id = ?
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
''').fetchall()
|
''', (player_id, )).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return jsonify([dict(r) for r in rows])
|
return jsonify([dict(r) for r in rows])
|
||||||
@@ -138,7 +151,7 @@ def create_command():
|
|||||||
if not data:
|
if not data:
|
||||||
return jsonify({'error': 'no data'}), 400
|
return jsonify({'error': 'no data'}), 400
|
||||||
|
|
||||||
required = ['town_id', 'type', 'payload']
|
required = ['town_id', 'type', 'payload', 'player_id']
|
||||||
for field in required:
|
for field in required:
|
||||||
if field not in data:
|
if field not in data:
|
||||||
return jsonify({'error': f'missing field: {field}'}), 400
|
return jsonify({'error': f'missing field: {field}'}), 400
|
||||||
@@ -149,7 +162,7 @@ def create_command():
|
|||||||
|
|
||||||
# Reject if the Tampermonkey client is offline (no state push in last 150 s)
|
# Reject if the Tampermonkey client is offline (no state push in last 150 s)
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute('SELECT MAX(updated_at) AS last_seen FROM town_state').fetchone()
|
row = conn.execute('SELECT MAX(updated_at) AS last_seen FROM town_state WHERE player_id = ?', (data['player_id'], )).fetchone()
|
||||||
last_seen = row['last_seen'] if row else None
|
last_seen = row['last_seen'] if row else None
|
||||||
client_online = False
|
client_online = False
|
||||||
if last_seen:
|
if last_seen:
|
||||||
@@ -165,15 +178,16 @@ def create_command():
|
|||||||
|
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute('''
|
c.execute('''
|
||||||
INSERT INTO commands (town_id, town_name, type, payload, status, created_at, updated_at)
|
INSERT INTO commands (town_id, town_name, type, payload, status, created_at, updated_at, player_id)
|
||||||
VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)
|
||||||
''', (
|
''', (
|
||||||
str(data['town_id']),
|
str(data['town_id']),
|
||||||
data.get('town_name', ''),
|
data.get('town_name', ''),
|
||||||
cmd_type,
|
cmd_type,
|
||||||
json.dumps(data['payload']),
|
json.dumps(data['payload']),
|
||||||
datetime.utcnow().isoformat(),
|
datetime.utcnow().isoformat(),
|
||||||
datetime.utcnow().isoformat()
|
datetime.utcnow().isoformat(),
|
||||||
|
str(data['player_id'])
|
||||||
))
|
))
|
||||||
cmd_id = c.lastrowid
|
cmd_id = c.lastrowid
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -202,6 +216,7 @@ def cancel_command(cmd_id):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@dashboard.route('/dashboard/commands/fail-stale', methods=['POST'])
|
@dashboard.route('/dashboard/commands/fail-stale', methods=['POST'])
|
||||||
def fail_stale_commands():
|
def fail_stale_commands():
|
||||||
|
player_id = request.args.get('player_id')
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute('''
|
c.execute('''
|
||||||
@@ -209,8 +224,8 @@ def fail_stale_commands():
|
|||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
result_msg = 'Client went offline',
|
result_msg = 'Client went offline',
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE status IN ('pending', 'executing')
|
WHERE status IN ('pending', 'executing') AND player_id = ?
|
||||||
''', (datetime.utcnow().isoformat(),))
|
''', (datetime.utcnow().isoformat(), player_id))
|
||||||
affected = c.rowcount
|
affected = c.rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
window.fetchTowns = async function() {
|
window.fetchTowns = async function() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/dashboard/towns');
|
const res = await fetch('/dashboard/towns?player_id=' + window.PLAYER_ID);
|
||||||
window.towns = await res.json();
|
window.towns = await res.json();
|
||||||
window.renderTowns();
|
window.renderTowns();
|
||||||
window.updateServerStatus(true);
|
window.updateServerStatus(true);
|
||||||
@@ -22,14 +22,14 @@ window.fetchTowns = async function() {
|
|||||||
|
|
||||||
window.fetchClientStatus = async function() {
|
window.fetchClientStatus = async function() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/dashboard/client-status');
|
const res = await fetch('/dashboard/client-status?player_id=' + window.PLAYER_ID);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const isOnline = data.online === true;
|
const isOnline = data.online === true;
|
||||||
|
|
||||||
// Detect transition: online → offline
|
// Detect transition: online → offline
|
||||||
if (window.wasClientOnline === true && !isOnline) {
|
if (window.wasClientOnline === true && !isOnline) {
|
||||||
// Fail all queued commands immediately
|
// Fail all queued commands immediately
|
||||||
await fetch('/dashboard/commands/fail-stale', { method: 'POST' });
|
await fetch('/dashboard/commands/fail-stale?player_id=' + window.PLAYER_ID, { method: 'POST' });
|
||||||
window.fetchLog();
|
window.fetchLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ window.fetchClientStatus = async function() {
|
|||||||
|
|
||||||
window.fetchLog = async function() {
|
window.fetchLog = async function() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/dashboard/commands');
|
const res = await fetch('/dashboard/commands?player_id=' + window.PLAYER_ID);
|
||||||
const cmds = await res.json();
|
const cmds = await res.json();
|
||||||
window.renderLog(cmds);
|
window.renderLog(cmds);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -161,7 +161,8 @@ window.sendCommand = async function() {
|
|||||||
town_id: town.town_id,
|
town_id: town.town_id,
|
||||||
town_name: town.town_name,
|
town_name: town.town_name,
|
||||||
type,
|
type,
|
||||||
payload
|
payload,
|
||||||
|
player_id: window.PLAYER_ID
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -184,7 +185,7 @@ window.cancelCommand = async function(id) {
|
|||||||
|
|
||||||
window.fetchCaptchaStatus = async function() {
|
window.fetchCaptchaStatus = async function() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/dashboard/captcha-status');
|
const res = await fetch('/dashboard/captcha-status?player_id=' + window.PLAYER_ID);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const banner = document.getElementById('captcha-banner');
|
const banner = document.getElementById('captcha-banner');
|
||||||
if (!banner) return;
|
if (!banner) return;
|
||||||
|
|||||||
@@ -194,6 +194,9 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.PLAYER_ID = "{{ player_id }}";
|
||||||
|
</script>
|
||||||
<script src="/static/js/state.js"></script>
|
<script src="/static/js/state.js"></script>
|
||||||
<script src="/static/js/components/townViewer.js"></script>
|
<script src="/static/js/components/townViewer.js"></script>
|
||||||
<script src="/static/js/components/commandForm.js"></script>
|
<script src="/static/js/components/commandForm.js"></script>
|
||||||
|
|||||||
75
templates/index.html
Normal file
75
templates/index.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Grepolis Remote Dashboard - Select Player</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #1a1a24;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
.landing-container {
|
||||||
|
background: #2a2a36;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.player-card {
|
||||||
|
background: #3a3a46;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
display: block;
|
||||||
|
transition: background 0.2s, transform 0.1s;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border: 1px solid #4a4a56;
|
||||||
|
}
|
||||||
|
.player-card:hover {
|
||||||
|
background: #5a5a66;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #c8a44a;
|
||||||
|
}
|
||||||
|
.player-card span {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #c8a44a;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="landing-container">
|
||||||
|
<h1>⚔️ Grepolis Remote</h1>
|
||||||
|
<p>Select an active account to manage</p>
|
||||||
|
|
||||||
|
{% if not players %}
|
||||||
|
<p style="color: #ffaa55;">No players found! Install the Tampermonkey script and log into the game first.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in players %}
|
||||||
|
<a href="/player/{{ p.player_id }}" class="player-card">
|
||||||
|
<strong>{{ p.player }}</strong> <span>(ID: {{ p.player_id }})</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user