mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
feat(maigret): stylized PDF report from raw lookup (ref #845)
Turn a maigret --json simple lookup into a FORMAL, SecuBox-branded PDF dossier
(api/report.py, fpdf2): dark cyber masthead, a document metadata block
(REFERENCE/SUBJECT/GENERATED/SOURCE/SCOPE/CLASSIFICATION), a numbered §1 Summary
(sentence + stat cards), and §2 Findings as a coherent results TABLE (# /
platform / category / profile URL / details) — zebra rows, category colour-coding,
paginated with a repeating header. Latin-1 sanitised (unicode profile data never
crashes core fonts).
- GET /api/v1/maigret/lookup/{id}/report.pdf (JWT-gated, plain def →
threadpooled so the fpdf render never blocks the aggregator loop).
- Panel: 📄 PDF button per completed lookup → authed fetch → blob download.
- python3-fpdf2 dependency. 4 report tests (valid PDF, claimed-only, empty/
malformed safe, unicode safe) + 8 api tests green. Deployed; route live (401 gated).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
b2abaf714f
commit
493e2bd2c3
|
|
@ -205,6 +205,27 @@ def get_lookup(lookup_id: str):
|
|||
raise HTTPException(404, "not found")
|
||||
return json.loads(f.read_text())
|
||||
|
||||
@app.get("/lookup/{lookup_id}/report.pdf", dependencies=[Depends(require_jwt)])
|
||||
def lookup_report(lookup_id: str):
|
||||
"""Render a completed lookup into a styled, SecuBox-branded PDF dossier.
|
||||
Plain `def` → FastAPI threadpools it, so the (sync) fpdf render never blocks
|
||||
the shared aggregator loop."""
|
||||
if not _valid_id(lookup_id):
|
||||
raise HTTPException(400, "invalid lookup id")
|
||||
f = LOOKUPS_DIR / f"{lookup_id}.json"
|
||||
if not f.exists():
|
||||
raise HTTPException(404, "not found")
|
||||
record = json.loads(f.read_text())
|
||||
from . import report as _report
|
||||
try:
|
||||
pdf = _report.build_pdf(record)
|
||||
except Exception as e: # noqa: BLE001 — never 500 the panel over a render glitch
|
||||
raise HTTPException(500, f"report render failed: {e}")
|
||||
safe = _re.sub(r"[^A-Za-z0-9._-]", "_", str(record.get("username", "report")))[:40] or "report"
|
||||
from fastapi.responses import Response
|
||||
return Response(content=pdf, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="maigret-{safe}.pdf"'})
|
||||
|
||||
@app.delete("/lookup/{lookup_id}", dependencies=[Depends(require_jwt)])
|
||||
def delete_lookup(lookup_id: str, claims: dict = Depends(require_jwt)):
|
||||
if not _valid_id(lookup_id):
|
||||
|
|
|
|||
264
packages/secubox-maigret/api/report.py
Normal file
264
packages/secubox-maigret/api/report.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
"""
|
||||
SecuBox-Deb :: metalogue / maigret PDF report
|
||||
CyberMind — https://cybermind.fr
|
||||
|
||||
Turns a raw maigret `--json simple` report (stored on the lookup record as a
|
||||
JSON string in results.raw) into a FORMAL, SecuBox-branded PDF dossier: a
|
||||
titled masthead, a document metadata block, a numbered Summary, and the claimed
|
||||
accounts laid out as a single coherent results table (platform / category /
|
||||
profile / details), grouped by category and paginated with a repeating header.
|
||||
|
||||
Pure fpdf2 core fonts (Helvetica/Courier) — no embedded TTFs, no network. CPU
|
||||
work: callers on the event loop MUST run build_pdf via asyncio.to_thread.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fpdf import FPDF
|
||||
|
||||
# ── SecuBox cyber palette ────────────────────────────────────────────────────
|
||||
BG = (13, 17, 23) # cosmos dark ground
|
||||
BAND = (18, 24, 33) # header / footer / table-head band
|
||||
CARD = (23, 30, 41) # zebra row A
|
||||
ROWB = (16, 21, 29) # zebra row B
|
||||
LINE = (40, 52, 66) # hairline rules
|
||||
CYAN = (0, 212, 255) # primary accent
|
||||
GOLD = (201, 168, 76)
|
||||
GREEN = (0, 221, 68)
|
||||
TEXT = (232, 230, 217)
|
||||
MUTED = (120, 140, 155)
|
||||
RED = (255, 68, 102)
|
||||
|
||||
_CAT_COLOR = {
|
||||
"coding": CYAN, "social": (68, 136, 255), "blog": GOLD, "photo": (163, 113, 247),
|
||||
"gaming": GREEN, "video": RED, "music": (255, 153, 68), "finance": GREEN,
|
||||
"shopping": (255, 204, 0), "news": (150, 165, 180), "forum": (68, 136, 255),
|
||||
"tech": CYAN, "sport": GREEN, "hobby": (255, 153, 68),
|
||||
}
|
||||
|
||||
# table geometry (A4 usable width = 210 - 2*14 = 182 mm)
|
||||
COLS = [("#", 9), ("PLATFORM", 40), ("CATEGORY", 24), ("PROFILE URL", 72), ("DETAILS", 37)]
|
||||
ROW_H = 7.0
|
||||
MARGIN = 14.0
|
||||
|
||||
|
||||
def _cat_color(cat: str):
|
||||
return _CAT_COLOR.get((cat or "").lower(), (150, 165, 180))
|
||||
|
||||
|
||||
def _parse(results: Any) -> dict:
|
||||
if isinstance(results, dict) and "raw" in results:
|
||||
results = results["raw"]
|
||||
if isinstance(results, str):
|
||||
results = results.strip()
|
||||
if not results:
|
||||
return {}
|
||||
try:
|
||||
results = json.loads(results)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return results if isinstance(results, dict) else {}
|
||||
|
||||
|
||||
def _claimed(data: dict) -> list[dict]:
|
||||
out = []
|
||||
for name, site in (data or {}).items():
|
||||
if not isinstance(site, dict):
|
||||
continue
|
||||
st = site.get("status") or {}
|
||||
if str(st.get("status", "")).lower() != "claimed":
|
||||
continue
|
||||
tags = st.get("tags") or (site.get("site") or {}).get("tags") or []
|
||||
ids = st.get("ids") or {}
|
||||
extra = {k: v for k, v in ids.items()
|
||||
if k.lower() in ("name", "fullname", "company", "location", "id")
|
||||
and isinstance(v, str) and v.strip()}
|
||||
out.append({
|
||||
"site": name,
|
||||
"url": site.get("url_user") or st.get("url") or "",
|
||||
"category": (tags[0] if tags else "other"),
|
||||
"rank": site.get("rank") or 10_000_000,
|
||||
"details": " ".join(f"{k}: {v}" for k, v in extra.items()),
|
||||
})
|
||||
out.sort(key=lambda a: (a["category"], a["rank"], a["site"].lower()))
|
||||
return out
|
||||
|
||||
|
||||
class _Report(FPDF):
|
||||
def __init__(self, username: str, generated: str):
|
||||
super().__init__(orientation="P", unit="mm", format="A4")
|
||||
self.username = username
|
||||
self.generated = generated
|
||||
self.set_auto_page_break(True, margin=16)
|
||||
self.set_margins(MARGIN, 32, MARGIN)
|
||||
self.set_title(f"SecuBox OSINT report - {username}")
|
||||
self._in_table = False
|
||||
|
||||
# dark ground + masthead on every page
|
||||
def header(self):
|
||||
self.set_fill_color(*BG); self.rect(-1, -1, self.w + 2, self.h + 2, "F")
|
||||
self.set_fill_color(*BAND); self.rect(0, 0, self.w, 24, "F")
|
||||
self.set_xy(MARGIN, 5.5)
|
||||
self.set_font("Helvetica", "B", 14); self.set_text_color(*CYAN)
|
||||
self.cell(0, 7, "SECUBOX // OSINT IDENTITY REPORT")
|
||||
self.set_xy(MARGIN, 13)
|
||||
self.set_font("Courier", "", 8); self.set_text_color(*MUTED)
|
||||
self.cell(0, 5, f"metalogue - maigret - generated {self.generated} UTC")
|
||||
self.set_draw_color(*CYAN); self.set_line_width(0.5); self.line(0, 24, self.w, 24)
|
||||
# a continued table repeats its head at the top of each new page
|
||||
if self._in_table:
|
||||
self.set_y(30)
|
||||
self._table_head()
|
||||
|
||||
def footer(self):
|
||||
self.set_draw_color(*LINE); self.set_line_width(0.3)
|
||||
self.line(MARGIN, self.h - 13, self.w - MARGIN, self.h - 13)
|
||||
self.set_font("Courier", "", 8); self.set_text_color(*MUTED)
|
||||
self.set_y(-11)
|
||||
self.cell((self.w - 2 * MARGIN) / 2, 6, "CONFIDENTIAL - SecuBox metalogue")
|
||||
self.cell((self.w - 2 * MARGIN) / 2, 6, f"page {self.page_no()}", align="R")
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
def _fit(self, text: str, width: float) -> str:
|
||||
# core fonts are latin-1 only → replace unencodable chars (unicode names,
|
||||
# emoji in profile data) so a render never crashes.
|
||||
text = str(text or "").encode("latin-1", "replace").decode("latin-1")
|
||||
if self.get_string_width(text) <= width:
|
||||
return text
|
||||
while text and self.get_string_width(text + "...") > width:
|
||||
text = text[:-1]
|
||||
return (text + "...") if text else ""
|
||||
|
||||
def _label(self, text, color=MUTED, size=8.5):
|
||||
self.set_font("Courier", "", size); self.set_text_color(*color)
|
||||
|
||||
# ── sections ─────────────────────────────────────────────────────────────
|
||||
def metadata(self, ref, checked):
|
||||
self.set_y(30)
|
||||
rows = [
|
||||
("REFERENCE", ref or "-"),
|
||||
("SUBJECT", f"@{self.username}"),
|
||||
("GENERATED", f"{self.generated} UTC"),
|
||||
("SOURCE", "maigret - SecuBox metalogue"),
|
||||
("SCOPE", f"{checked} platforms checked"),
|
||||
("CLASSIFICATION", "CONFIDENTIAL"),
|
||||
]
|
||||
x, y = MARGIN, self.get_y()
|
||||
w = self.w - 2 * MARGIN
|
||||
self.set_fill_color(*CARD); self.rect(x, y, w, len(rows) * 6 + 4, "F")
|
||||
self.set_fill_color(*CYAN); self.rect(x, y, 1.4, len(rows) * 6 + 4, "F")
|
||||
yy = y + 2
|
||||
for k, v in rows:
|
||||
self.set_xy(x + 6, yy)
|
||||
self.set_font("Courier", "", 8); self.set_text_color(*MUTED)
|
||||
self.cell(38, 6, k)
|
||||
self.set_font("Courier", "B", 9)
|
||||
self.set_text_color(*(GOLD if k == "CLASSIFICATION" else TEXT))
|
||||
self.cell(w - 46, 6, self._fit(v, w - 52))
|
||||
yy += 6
|
||||
self.set_y(y + len(rows) * 6 + 4)
|
||||
|
||||
def h2(self, text):
|
||||
self.ln(5)
|
||||
self.set_font("Helvetica", "B", 11); self.set_text_color(*CYAN)
|
||||
y = self.get_y(); self.cell(0, 7, text, new_x="LMARGIN", new_y="NEXT")
|
||||
self.set_draw_color(*CYAN); self.set_line_width(0.3)
|
||||
self.line(MARGIN, y + 7.3, self.w - MARGIN, y + 7.3)
|
||||
self.ln(2)
|
||||
|
||||
def summary(self, found, checked, cats):
|
||||
self.h2("1. SUMMARY")
|
||||
self.set_font("Helvetica", "", 10.5); self.set_text_color(*TEXT)
|
||||
self.set_x(MARGIN)
|
||||
self.multi_cell(0, 5.6,
|
||||
f"maigret identified {found} claimed account(s) for the identity "
|
||||
f"@{self.username} across {checked} platforms checked, spanning "
|
||||
f"{cats} category(ies). Findings are enumerated in section 2.")
|
||||
self.ln(2)
|
||||
cards = [("ACCOUNTS FOUND", str(found), GREEN),
|
||||
("PLATFORMS CHECKED", str(checked), CYAN),
|
||||
("CATEGORIES", str(cats), GOLD)]
|
||||
gap = 4; w = (self.w - 2 * MARGIN - gap * 2) / 3
|
||||
x0, y0 = MARGIN, self.get_y()
|
||||
for i, (label, value, color) in enumerate(cards):
|
||||
x = x0 + i * (w + gap)
|
||||
self.set_fill_color(*CARD); self.rect(x, y0, w, 18, "F")
|
||||
self.set_fill_color(*color); self.rect(x, y0, 1.4, 18, "F")
|
||||
self.set_xy(x + 5, y0 + 2.5)
|
||||
self.set_font("Helvetica", "B", 18); self.set_text_color(*color)
|
||||
self.cell(w - 6, 8, value)
|
||||
self.set_xy(x + 5, y0 + 12)
|
||||
self.set_font("Courier", "", 7); self.set_text_color(*MUTED)
|
||||
self.cell(w - 6, 4, label)
|
||||
self.set_y(y0 + 18)
|
||||
|
||||
def _table_head(self):
|
||||
x, y = MARGIN, self.get_y()
|
||||
self.set_fill_color(*BAND); self.rect(x, y, self.w - 2 * MARGIN, ROW_H, "F")
|
||||
self.set_font("Helvetica", "B", 8); self.set_text_color(*CYAN)
|
||||
cx = x
|
||||
for title, w in COLS:
|
||||
self.set_xy(cx + 2, y + 1.6)
|
||||
self.cell(w - 3, 4, title)
|
||||
cx += w
|
||||
self.set_draw_color(*CYAN); self.set_line_width(0.3)
|
||||
self.line(x, y + ROW_H, self.w - MARGIN, y + ROW_H)
|
||||
self.set_y(y + ROW_H)
|
||||
|
||||
def _row(self, n, acc, zebra):
|
||||
x, y = MARGIN, self.get_y()
|
||||
color = _cat_color(acc["category"])
|
||||
self.set_fill_color(*(CARD if zebra else ROWB))
|
||||
self.rect(x, y, self.w - 2 * MARGIN, ROW_H, "F")
|
||||
self.set_fill_color(*color); self.rect(x, y, 1.2, ROW_H, "F")
|
||||
cx = x
|
||||
vals = [
|
||||
(str(n), MUTED, "Courier", "", 8),
|
||||
(acc["site"], TEXT, "Helvetica", "B", 9),
|
||||
(acc["category"], color, "Courier", "", 8),
|
||||
(acc["url"], CYAN, "Courier", "", 7.5),
|
||||
(acc["details"] or "-", MUTED, "Courier", "", 7.5),
|
||||
]
|
||||
for (val, col, fam, style, size), (_, w) in zip(vals, COLS):
|
||||
self.set_font(fam, style, size); self.set_text_color(*col)
|
||||
self.set_xy(cx + 2, y + 2.0)
|
||||
link = acc["url"] if val == acc["url"] and acc["url"] else ""
|
||||
self.cell(w - 3, 3.4, self._fit(val, w - 4), link=link)
|
||||
cx += w
|
||||
self.set_draw_color(*LINE); self.set_line_width(0.15)
|
||||
self.line(x, y + ROW_H, self.w - MARGIN, y + ROW_H)
|
||||
self.set_y(y + ROW_H)
|
||||
|
||||
def findings(self, accounts):
|
||||
self.h2("2. FINDINGS")
|
||||
if not accounts:
|
||||
self.set_font("Helvetica", "", 11); self.set_text_color(*MUTED)
|
||||
self.cell(0, 8, "No claimed accounts were found for this identity.", new_x="LMARGIN", new_y="NEXT")
|
||||
return
|
||||
self._table_head()
|
||||
self._in_table = True
|
||||
for i, acc in enumerate(accounts, 1):
|
||||
if self.get_y() > self.h - (ROW_H + 16):
|
||||
self.add_page() # header() repaints the table head
|
||||
self._row(i, acc, zebra=(i % 2 == 0))
|
||||
self._in_table = False
|
||||
|
||||
|
||||
def build_pdf(record: dict) -> bytes:
|
||||
username = str(record.get("username", "unknown"))
|
||||
generated = str(record.get("finished_at") or record.get("started_at") or "")[:19].replace("T", " ")
|
||||
ref = str(record.get("id", ""))
|
||||
data = _parse(record.get("results"))
|
||||
accounts = _claimed(data)
|
||||
cats = len({a["category"] for a in accounts})
|
||||
|
||||
pdf = _Report(username, generated)
|
||||
pdf.add_page()
|
||||
pdf.metadata(ref=ref, checked=len(data))
|
||||
pdf.summary(found=len(accounts), checked=len(data), cats=cats)
|
||||
pdf.findings(accounts)
|
||||
return bytes(pdf.output())
|
||||
|
|
@ -7,7 +7,8 @@ Standards-Version: 4.6.2
|
|||
|
||||
Package: secubox-maigret
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0.0), debootstrap, lxc, jq
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0.0), debootstrap, lxc, jq,
|
||||
python3-fpdf2
|
||||
Description: Maigret identity/username OSINT collector (LXC-sandboxed)
|
||||
Wraps the Maigret CLI OSINT tool inside a dedicated debootstrap/LXC
|
||||
sandbox. Given a username, Maigret searches 3000+ sites for matching
|
||||
|
|
|
|||
53
packages/secubox-maigret/tests/test_report.py
Normal file
53
packages/secubox-maigret/tests/test_report.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
"""maigret PDF report generator — build a valid, styled PDF from a lookup record."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
fpdf = pytest.importorskip("fpdf") # skip cleanly if fpdf2 isn't in the env
|
||||
from api import report # noqa: E402
|
||||
|
||||
|
||||
def _rec(raw, **kw):
|
||||
base = {"id": "01ab23cd", "username": "torvalds", "status": "completed",
|
||||
"started_at": "2026-07-12T13:00:00Z", "finished_at": "2026-07-12T13:02:00Z",
|
||||
"results": {"raw": raw if isinstance(raw, str) else json.dumps(raw)}}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
SAMPLE = {
|
||||
"GitHub": {"url_user": "https://github.com/torvalds", "rank": 10,
|
||||
"status": {"status": "Claimed", "tags": ["coding"],
|
||||
"ids": {"name": "Linus Torvalds", "company": "Linux Foundation"}}},
|
||||
"Instagram": {"url_user": "https://instagram.com/torvalds/", "rank": 5,
|
||||
"status": {"status": "Claimed", "tags": ["social"], "ids": {}}},
|
||||
"NotFound": {"status": {"status": "Available"}},
|
||||
}
|
||||
|
||||
|
||||
def test_build_pdf_is_valid_pdf():
|
||||
pdf = report.build_pdf(_rec(SAMPLE))
|
||||
assert pdf[:4] == b"%PDF" and len(pdf) > 1000
|
||||
|
||||
|
||||
def test_only_claimed_accounts_counted():
|
||||
data = report._parse(_rec(SAMPLE)["results"])
|
||||
claimed = report._claimed(data)
|
||||
assert {a["site"] for a in claimed} == {"GitHub", "Instagram"} # 'Available' dropped
|
||||
gh = next(a for a in claimed if a["site"] == "GitHub")
|
||||
assert gh["category"] == "coding" and "Linus Torvalds" in gh["details"]
|
||||
|
||||
|
||||
def test_empty_and_malformed_never_crash():
|
||||
assert report.build_pdf(_rec(""))[:4] == b"%PDF" # no results
|
||||
assert report.build_pdf(_rec("not json{"))[:4] == b"%PDF" # malformed
|
||||
assert report.build_pdf({"username": "x"})[:4] == b"%PDF" # no results key
|
||||
|
||||
|
||||
def test_unicode_in_profile_data_is_safe():
|
||||
data = {"Medium": {"url_user": "https://medium.com/@x", "rank": 1,
|
||||
"status": {"status": "Claimed", "tags": ["blog"],
|
||||
"ids": {"name": "José Ñoño 日本語"}}}}
|
||||
assert report.build_pdf(_rec(data))[:4] == b"%PDF" # latin-1 sanitised, no crash
|
||||
|
|
@ -246,6 +246,7 @@
|
|||
<td>${fmtDate(l.started_at)}</td>
|
||||
<td>
|
||||
<button class="btn lk-act" style="padding:0.25rem 0.5rem;font-size:0.72rem" data-id="${esc(l.id)}" data-op="view">View</button>
|
||||
${l.status === 'completed' ? `<button class="btn lk-act" style="padding:0.25rem 0.5rem;font-size:0.72rem" data-id="${esc(l.id)}" data-op="pdf">📄 PDF</button>` : ''}
|
||||
<button class="btn danger lk-act" style="padding:0.25rem 0.5rem;font-size:0.72rem" data-id="${esc(l.id)}" data-op="delete">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
|
|
@ -264,7 +265,9 @@
|
|||
: '<tr><td colspan="5" class="muted">No lookups yet</td></tr>';
|
||||
document.querySelectorAll('.lk-act').forEach(b => b.addEventListener('click', () => {
|
||||
const id = b.dataset.id, op = b.dataset.op;
|
||||
if (op === 'view') viewLookup(id); else if (op === 'delete') deleteLookup(id);
|
||||
if (op === 'view') viewLookup(id);
|
||||
else if (op === 'delete') deleteLookup(id);
|
||||
else if (op === 'pdf') downloadPdf(id);
|
||||
}));
|
||||
const active = items.some(l => l.status === 'pending' || l.status === 'running');
|
||||
if (active) startListPoll(); else stopListPoll();
|
||||
|
|
@ -272,6 +275,25 @@
|
|||
function startListPoll() { if (!listPollTimer) listPollTimer = setInterval(loadLookups, 5000); }
|
||||
function stopListPoll() { if (listPollTimer) { clearInterval(listPollTimer); listPollTimer = null; } }
|
||||
|
||||
async function downloadPdf(id) {
|
||||
// The report endpoint is JWT-gated, so fetch with the bearer header
|
||||
// then trigger a client-side download of the returned PDF blob.
|
||||
toast('Generating PDF report…');
|
||||
try {
|
||||
const res = await fetch(API + '/lookup/' + encodeURIComponent(id) + '/report.pdf',
|
||||
{ headers: { 'Authorization': token() ? 'Bearer ' + token() : '' } });
|
||||
if (res.status === 401) { location.href = '/login.html?redirect=' + encodeURIComponent(location.pathname); return; }
|
||||
if (!res.ok) { toast('PDF failed: HTTP ' + res.status, 'error'); return; }
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'maigret-' + id + '.pdf';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||
toast('PDF report downloaded', 'success');
|
||||
} catch (e) { toast('PDF error: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function viewLookup(id) {
|
||||
document.getElementById('resultTitle').textContent = 'Lookup ' + id;
|
||||
document.getElementById('resultContent').innerHTML = '<div class="loader"></div> Loading…';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user