fix: guide link IDOR, full team-api IDOR hardening, loading overlay
- guide link now server-side replaced to /team/<idx>/guide (no /team/0 403) - _check_team_host() applied to ALL team endpoints (login, info, targets, status, guide, portal, ssh-ws): host must match team domain; panel/gemastik host only with admin session. Cross-domain session reuse -> 403. - host check BEFORE auth on info/targets (no team-existence oracle) - loading overlay (spinner + text) on start/stop all-team/set; JS util showLoading/hideLoading
This commit is contained in:
1 parent
01ffc05a11
commit
72382c43d0
4 files changed
+93
-19
No files matched your search
+51
-15
@@ -88,6 +88,7 @@ async def index(req: Request):
|
|||||||
if t.get("slug") == slug:
|
if t.get("slug") == slug:
|
||||||
html = (BASE_DIR / "static" / "team.html").read_text()
|
html = (BASE_DIR / "static" / "team.html").read_text()
|
||||||
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{t["index"]}"')
|
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{t["index"]}"')
|
||||||
|
html = html.replace('href="/team/0/guide"', f'href="/team/{t["index"]}/guide"')
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
return HTMLResponse("<h2 style='font-family:sans-serif;color:#888;padding:40px'>Team tidak ditemukan: " + slug + "</h2>", status_code=404)
|
return HTMLResponse("<h2 style='font-family:sans-serif;color:#888;padding:40px'>Team tidak ditemukan: " + slug + "</h2>", status_code=404)
|
||||||
if not _authorized(req):
|
if not _authorized(req):
|
||||||
@@ -116,14 +117,18 @@ async def team_portal(idx: int, req: Request):
|
|||||||
if not (td / "state.json").exists():
|
if not (td / "state.json").exists():
|
||||||
raise HTTPException(404, "Team not found")
|
raise HTTPException(404, "Team not found")
|
||||||
st = json.loads((td / "state.json").read_text())
|
st = json.loads((td / "state.json").read_text())
|
||||||
host = (req.headers.get("host") or "").split(":")[0]
|
if not _check_team_host(req, st):
|
||||||
# host check: allow panel domain (uses explicit /team/N link for admin preview)
|
|
||||||
# and the team's own <slug>.domain; block cross-team access.
|
|
||||||
if not (host == f"panel.gemastik.imrnes.team" or host == f"gemastik.imrnes.team"
|
|
||||||
or host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")):
|
|
||||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||||
|
if _team_authorized(req, idx): # logged in -> show portal directly
|
||||||
|
html = (BASE_DIR / "static" / "team.html").read_text()
|
||||||
|
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
||||||
|
# server-side: fix guide link so non-JS / pre-JS clicks never hit /team/0
|
||||||
|
html = html.replace('href="/team/0/guide"', f'href="/team/{idx}/guide"')
|
||||||
|
return HTMLResponse(html)
|
||||||
|
# not logged in -> show a stripped landing/login page (no admin info)
|
||||||
html = (BASE_DIR / "static" / "team.html").read_text()
|
html = (BASE_DIR / "static" / "team.html").read_text()
|
||||||
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
||||||
|
html = html.replace('href="/team/0/guide"', f'href="/team/{idx}/guide"')
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
@@ -131,10 +136,10 @@ async def team_portal(idx: int, req: Request):
|
|||||||
async def team_guide(idx: int, req: Request):
|
async def team_guide(idx: int, req: Request):
|
||||||
"""Public SSH/attack guide for a team. Must be accessed via that team's own domain."""
|
"""Public SSH/attack guide for a team. Must be accessed via that team's own domain."""
|
||||||
td = orch.TEAMS_DIR / f"team{idx}"
|
td = orch.TEAMS_DIR / f"team{idx}"
|
||||||
st = json.loads((td / "state.json").read_text()) if (td / "state.json").exists() else {}
|
if not (td / "state.json").exists():
|
||||||
host = (req.headers.get("host") or "").split(":")[0]
|
raise HTTPException(404, "Team not found")
|
||||||
if not (host == f"panel.gemastik.imrnes.team" or host == f"gemastik.imrnes.team"
|
st = json.loads((td / "state.json").read_text())
|
||||||
or host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")):
|
if not _check_team_host(req, st):
|
||||||
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||||
html = (BASE_DIR / "static" / "guide.html").read_text()
|
html = (BASE_DIR / "static" / "guide.html").read_text()
|
||||||
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
html = html.replace('name="team-id" content="0"', f'name="team-id" content="{idx}"')
|
||||||
@@ -149,6 +154,8 @@ async def api_team_login(idx: int, req: Request):
|
|||||||
if not (td / "state.json").exists():
|
if not (td / "state.json").exists():
|
||||||
raise HTTPException(404, "Team not found")
|
raise HTTPException(404, "Team not found")
|
||||||
st = json.loads((td / "state.json").read_text())
|
st = json.loads((td / "state.json").read_text())
|
||||||
|
if not _check_team_host(req, st):
|
||||||
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||||
data = await req.json()
|
data = await req.json()
|
||||||
pw = data.get("pass", "")
|
pw = data.get("pass", "")
|
||||||
if pw != st.get("ssh_pass"):
|
if pw != st.get("ssh_pass"):
|
||||||
@@ -176,6 +183,18 @@ def _team_authorized(req: Request, idx: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _check_team_host(req: Request, st: dict) -> bool:
|
||||||
|
"""IDOR guard: host must be this team's own domain (or localhost).
|
||||||
|
panel.gemastik / gemastik.imrnes.team only allowed with a valid ADMIN session."""
|
||||||
|
host = (req.headers.get("host") or "").split(":")[0]
|
||||||
|
if host == st.get("domain"):
|
||||||
|
return True
|
||||||
|
if host.startswith("127.0.0.1") or host.startswith("localhost"):
|
||||||
|
return True
|
||||||
|
if host in ("panel.gemastik.imrnes.team", "gemastik.imrnes.team"):
|
||||||
|
return _authorized(req) # admin preview only
|
||||||
|
return False
|
||||||
|
|
||||||
@app.get("/api/team/{idx}/session")
|
@app.get("/api/team/{idx}/session")
|
||||||
async def api_team_session(idx: int, req: Request):
|
async def api_team_session(idx: int, req: Request):
|
||||||
"""True when this browser has a valid team session for idx."""
|
"""True when this browser has a valid team session for idx."""
|
||||||
@@ -183,7 +202,15 @@ async def api_team_session(idx: int, req: Request):
|
|||||||
|
|
||||||
@app.get("/api/team/{idx}/targets")
|
@app.get("/api/team/{idx}/targets")
|
||||||
async def api_team_targets(idx: int, req: Request):
|
async def api_team_targets(idx: int, req: Request):
|
||||||
"""Public: list of enemy teams' services (attack targets) for this team's portal."""
|
"""Team targets — requires team login + own host."""
|
||||||
|
td = orch.TEAMS_DIR / f"team{idx}"
|
||||||
|
if not (td / "state.json").exists():
|
||||||
|
raise HTTPException(404, "Team not found")
|
||||||
|
st_self = json.loads((td / "state.json").read_text())
|
||||||
|
if not _check_team_host(req, st_self):
|
||||||
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||||
|
if not _team_authorized(req, idx):
|
||||||
|
raise HTTPException(401, "Login portal team dulu")
|
||||||
out = []
|
out = []
|
||||||
for d in sorted(orch.TEAMS_DIR.glob("team*")):
|
for d in sorted(orch.TEAMS_DIR.glob("team*")):
|
||||||
if not (d / "state.json").exists():
|
if not (d / "state.json").exists():
|
||||||
@@ -207,13 +234,15 @@ async def api_team_targets(idx: int, req: Request):
|
|||||||
|
|
||||||
@app.get("/api/team/{idx}/info")
|
@app.get("/api/team/{idx}/info")
|
||||||
async def api_team_info(idx: int, req: Request):
|
async def api_team_info(idx: int, req: Request):
|
||||||
"""Team portal info — requires team login; no admin secrets."""
|
"""Team portal info — requires team login + own host; no admin secrets."""
|
||||||
if not _team_authorized(req, idx):
|
|
||||||
raise HTTPException(401, "Login portal team dulu")
|
|
||||||
td = orch.TEAMS_DIR / f"team{idx}"
|
td = orch.TEAMS_DIR / f"team{idx}"
|
||||||
if not (td / "state.json").exists():
|
if not (td / "state.json").exists():
|
||||||
raise HTTPException(404, "Team not found")
|
raise HTTPException(404, "Team not found")
|
||||||
st = json.loads((td / "state.json").read_text())
|
st = json.loads((td / "state.json").read_text())
|
||||||
|
if not _check_team_host(req, st):
|
||||||
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||||
|
if not _team_authorized(req, idx):
|
||||||
|
raise HTTPException(401, "Login portal team dulu")
|
||||||
return {"team": {
|
return {"team": {
|
||||||
"index": st.get("index"),
|
"index": st.get("index"),
|
||||||
"label": st.get("label"),
|
"label": st.get("label"),
|
||||||
@@ -227,12 +256,14 @@ async def api_team_info(idx: int, req: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/team/{idx}/status")
|
@app.get("/api/team/{idx}/status")
|
||||||
async def api_team_status(idx: int):
|
async def api_team_status(idx: int, req: Request):
|
||||||
"""Public: SLA status for a team's challenges (no auth — read-only health)."""
|
"""SLA status for a team's challenges (read-only health, own-host only)."""
|
||||||
td = orch.TEAMS_DIR / f"team{idx}"
|
td = orch.TEAMS_DIR / f"team{idx}"
|
||||||
if not (td / "state.json").exists():
|
if not (td / "state.json").exists():
|
||||||
raise HTTPException(404, "Team not found")
|
raise HTTPException(404, "Team not found")
|
||||||
st = json.loads ((td / "state.json").read_text())
|
st = json.loads ((td / "state.json").read_text())
|
||||||
|
if not _check_team_host(req, st):
|
||||||
|
raise HTTPException(403, "Akses team lain tidak diizinkan")
|
||||||
recv_port = st["ports"]["receiver"]
|
recv_port = st["ports"]["receiver"]
|
||||||
# use team's receiver admin creds (server-side only; never sent to browser)
|
# use team's receiver admin creds (server-side only; never sent to browser)
|
||||||
au, ap = st.get("admin_user", ""), st.get("admin_pass", "")
|
au, ap = st.get("admin_user", ""), st.get("admin_pass", "")
|
||||||
@@ -534,6 +565,11 @@ async def team_ssh_ws(ws: WebSocket, idx: int):
|
|||||||
return
|
return
|
||||||
td = orch.TEAMS_DIR / f"team{idx}"
|
td = orch.TEAMS_DIR / f"team{idx}"
|
||||||
st = json.loads((td / "state.json").read_text())
|
st = json.loads((td / "state.json").read_text())
|
||||||
|
# host check (IDOR): only this team's domain can open its SSH
|
||||||
|
host = (ws.headers.get("host") or "").split(":")[0]
|
||||||
|
if not (host == st.get("domain") or host.startswith("127.0.0.1") or host.startswith("localhost")):
|
||||||
|
await ws.close(code=4003, reason="wrong host")
|
||||||
|
return
|
||||||
if chall not in st.get("ports", {}):
|
if chall not in st.get("ports", {}):
|
||||||
await ws.close(code=4002, reason="unknown challenge")
|
await ws.close(code=4002, reason="unknown challenge")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -96,13 +96,13 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>📞 Butuh Bantuan?</h2>
|
<h2>📞 Butuh Bantuan?</h2>
|
||||||
<p>Hubungi panitia untuk: reset password SSH, restart container, atau lapor service down. Portal ini untuk peserta — panel admin terpisah.</p>
|
<p>Hubungi panitia untuk: reset password SSH, restart container, atau lapor service down. Portal ini untuk peserta — panel admin terpisah.</p>
|
||||||
<p style="margin-top:8px"><a href="/team/0" id="portalLink">← Kembali ke portal tim</a></p>
|
<p style="margin-top:8px"><a href="/team/0" data-portal-link>← Kembali ke portal tim</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
|
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
|
||||||
document.getElementById('portalLink').href = `/team/${TEAM_ID}`;
|
document.querySelectorAll('[data-portal-link]').forEach(a => a.href = `/team/${TEAM_ID}`);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+36
-1
@@ -83,6 +83,15 @@
|
|||||||
}
|
}
|
||||||
.toast.show { opacity:1; transform:translateY(0); }
|
.toast.show { opacity:1; transform:translateY(0); }
|
||||||
.toast.err { border-color:#f87171; color:#fca5a5; }
|
.toast.err { border-color:#f87171; color:#fca5a5; }
|
||||||
|
/* loading overlay */
|
||||||
|
#loadingOverlay {
|
||||||
|
display:none; position:fixed; inset:0; background:rgba(4,8,14,0.82); z-index:2000;
|
||||||
|
align-items:center; justify-content:center; flex-direction:column; gap:18px;
|
||||||
|
}
|
||||||
|
#loadingOverlay.show { display:flex; }
|
||||||
|
.loader { width:44px; height:44px; border:4px solid #2a3444; border-top-color:#38bdf8; border-radius:50%; animation:spin 0.9s linear infinite; }
|
||||||
|
@keyframes spin { to { transform:rotate(360deg); } }
|
||||||
|
#loadingText { color:#dbe6f4; font-size:14px; text-align:center; padding:0 24px; line-height:1.6; }
|
||||||
.history-box {
|
.history-box {
|
||||||
background:#0a101f; border:1px solid #1e3a5f; border-radius:10px; padding:14px;
|
background:#0a101f; border:1px solid #1e3a5f; border-radius:10px; padding:14px;
|
||||||
font-size:11px; color:#7dd3fc; max-height:300px; overflow-y:auto; white-space:pre-wrap;
|
font-size:11px; color:#7dd3fc; max-height:300px; overflow-y:auto; white-space:pre-wrap;
|
||||||
@@ -219,6 +228,12 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<!-- loading overlay -->
|
||||||
|
<div id="loadingOverlay">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<div id="loadingText">Memproses…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let CURRENT = null;
|
let CURRENT = null;
|
||||||
let REFRESH_MS = 15000;
|
let REFRESH_MS = 15000;
|
||||||
@@ -237,6 +252,16 @@ function toast(msg, err=false) {
|
|||||||
setTimeout(() => t.className = 'toast', 3000);
|
setTimeout(() => t.className = 'toast', 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showLoading(html) {
|
||||||
|
const ov = document.getElementById('loadingOverlay');
|
||||||
|
document.getElementById('loadingText').innerHTML = html;
|
||||||
|
ov.classList.add('show');
|
||||||
|
return ov;
|
||||||
|
}
|
||||||
|
function hideLoading() {
|
||||||
|
document.getElementById('loadingOverlay').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
}
|
}
|
||||||
@@ -448,10 +473,12 @@ async function setTeams() {
|
|||||||
if (inp && inp.value.trim()) labels[i] = inp.value.trim();
|
if (inp && inp.value.trim()) labels[i] = inp.value.trim();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
showLoading(`Membuat ${n} team…<br>Generate compose, flags, domain, Traefik config`);
|
||||||
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels})});
|
const d = await api('/api/teams/set', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({count: n, labels})});
|
||||||
toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} · total ${d.total}`, false);
|
toast(`Team dibuat: ${d.created.join(', ') || 'tidak ada yang baru'} · total ${d.total}`, false);
|
||||||
loadTeams();
|
loadTeams();
|
||||||
} catch (e) { toast(e.message, true); }
|
} catch (e) { toast(e.message, true); }
|
||||||
|
finally { hideLoading(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function teamCountChanged() {
|
function teamCountChanged() {
|
||||||
@@ -468,20 +495,28 @@ function teamCountChanged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startTeam(idx) {
|
async function startTeam(idx) {
|
||||||
try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start (build bisa makan waktu)`, false); setTimeout(loadTeams, 3000); }
|
const ov = showLoading(`Menyiapkan Team ${idx}…<br>Membangun & start container (bisa butuh 1-3 menit)`);
|
||||||
|
try { await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} start`, false); }
|
||||||
catch (e) { toast(e.message, true); }
|
catch (e) { toast(e.message, true); }
|
||||||
|
finally { hideLoading(); setTimeout(loadTeams, 2500); }
|
||||||
}
|
}
|
||||||
async function stopTeam(idx) {
|
async function stopTeam(idx) {
|
||||||
|
const ov = showLoading(`Menghentikan Team ${idx}…`);
|
||||||
try { await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} stop`, false); loadTeams(); }
|
try { await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({index: idx})}); toast(`Team ${idx} stop`, false); loadTeams(); }
|
||||||
catch (e) { toast(e.message, true); }
|
catch (e) { toast(e.message, true); }
|
||||||
|
finally { hideLoading(); }
|
||||||
}
|
}
|
||||||
async function startAllTeams() {
|
async function startAllTeams() {
|
||||||
|
const ov = showLoading('Start SEMUA team…<br>Ini bisa butuh beberapa menit (build + boot + set password)');
|
||||||
try { const d = await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Start semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); setTimeout(loadTeams, 4000); }
|
try { const d = await api('/api/teams/start', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Start semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); setTimeout(loadTeams, 4000); }
|
||||||
catch (e) { toast(e.message, true); }
|
catch (e) { toast(e.message, true); }
|
||||||
|
finally { hideLoading(); }
|
||||||
}
|
}
|
||||||
async function stopAllTeams() {
|
async function stopAllTeams() {
|
||||||
|
const ov = showLoading('Menghentikan SEMUA team…');
|
||||||
try { const d = await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Stop semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); loadTeams(); }
|
try { const d = await api('/api/teams/stop', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}); toast(`Stop semua: ${d.results.filter(r=>r.ok).length}/${d.results.length} ok`, false); loadTeams(); }
|
||||||
catch (e) { toast(e.message, true); }
|
catch (e) { toast(e.message, true); }
|
||||||
|
finally { hideLoading(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function randomizeTeam(idx) {
|
async function randomizeTeam(idx) {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
<input type="password" id="loginPass" placeholder="password SSH tim" autocomplete="off">
|
<input type="password" id="loginPass" placeholder="password SSH tim" autocomplete="off">
|
||||||
<button onclick="doLogin()" style="width:100%;margin-top:16px">Masuk Portal</button>
|
<button onclick="doLogin()" style="width:100%;margin-top:16px">Masuk Portal</button>
|
||||||
<div class="login-err" id="loginErr"></div>
|
<div class="login-err" id="loginErr"></div>
|
||||||
<div class="login-note">Belum punya password? Hubungi panitia.<br>Tutorial SSH: <a href="/team/0/guide" id="guideLink">baca panduan</a></div>
|
<div class="login-note">Belum punya password? Hubungi panitia.<br>Tutorial SSH: <a href="/team/0/guide" data-guide-link>baca panduan</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,6 +193,9 @@
|
|||||||
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
|
const TEAM_ID = parseInt(document.querySelector('meta[name="team-id"]').content || '0', 10);
|
||||||
let term = null, ws = null;
|
let term = null, ws = null;
|
||||||
|
|
||||||
|
// fix all guide links immediately (no auth needed) — /team/0 would 403
|
||||||
|
document.querySelectorAll('[data-guide-link]').forEach(a => a.href = `/team/${TEAM_ID}/guide`);
|
||||||
|
|
||||||
async function api(url, opts) { const r = await fetch(url, opts); return r.json(); }
|
async function api(url, opts) { const r = await fetch(url, opts); return r.json(); }
|
||||||
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||||
function showView(v) {
|
function showView(v) {
|
||||||
|
|||||||
Reference in new issue
Block a user