chore: enforce strict typing and internal naming conventions across web components
This commit is contained in:
@@ -353,12 +353,12 @@ def deploy(
|
||||
import subprocess
|
||||
import sys
|
||||
console.print(f"[green]Starting DECNET API on port {api_port}...[/]")
|
||||
env = os.environ.copy()
|
||||
env["DECNET_INGEST_LOG_FILE"] = effective_log_file
|
||||
_env: dict[str, str] = os.environ.copy()
|
||||
_env["DECNET_INGEST_LOG_FILE"] = str(effective_log_file)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "decnet.web.api:app", "--host", "0.0.0.0", "--port", str(api_port)],
|
||||
env=env,
|
||||
env=_env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import timedelta
|
||||
from typing import Any, AsyncGenerator
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, status
|
||||
@@ -22,7 +22,7 @@ from decnet.web.ingester import log_ingestion_worker
|
||||
import asyncio
|
||||
|
||||
repo: SQLiteRepository = SQLiteRepository()
|
||||
ingestion_task: asyncio.Task | None = None
|
||||
ingestion_task: Optional[asyncio.Task[Any]] = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -30,8 +30,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
global ingestion_task
|
||||
await repo.initialize()
|
||||
# Create default admin if no users exist
|
||||
admin_user: dict[str, Any] | None = await repo.get_user_by_username("admin")
|
||||
if not admin_user:
|
||||
_admin_user: Optional[dict[str, Any]] = await repo.get_user_by_username("admin")
|
||||
if not _admin_user:
|
||||
await repo.create_user(
|
||||
{
|
||||
"uuid": str(uuid.uuid4()),
|
||||
@@ -71,19 +71,19 @@ oauth2_scheme: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="/api/v1/aut
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
|
||||
credentials_exception = HTTPException(
|
||||
_credentials_exception: HTTPException = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_uuid: str | None = payload.get("uuid")
|
||||
if user_uuid is None:
|
||||
raise credentials_exception
|
||||
_payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
_user_uuid: Optional[str] = _payload.get("uuid")
|
||||
if _user_uuid is None:
|
||||
raise _credentials_exception
|
||||
return _user_uuid
|
||||
except jwt.PyJWTError:
|
||||
raise credentials_exception
|
||||
return user_uuid
|
||||
raise _credentials_exception
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
@@ -111,37 +111,37 @@ class LogsResponse(BaseModel):
|
||||
|
||||
@app.post("/api/v1/auth/login", response_model=Token)
|
||||
async def login(request: LoginRequest) -> dict[str, Any]:
|
||||
user: dict[str, Any] | None = await repo.get_user_by_username(request.username)
|
||||
if not user or not verify_password(request.password, user["password_hash"]):
|
||||
_user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username)
|
||||
if not _user or not verify_password(request.password, _user["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token_expires: timedelta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
_access_token_expires: timedelta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
# Token uses uuid instead of sub
|
||||
access_token: str = create_access_token(
|
||||
data={"uuid": user["uuid"]}, expires_delta=access_token_expires
|
||||
_access_token: str = create_access_token(
|
||||
data={"uuid": _user["uuid"]}, expires_delta=_access_token_expires
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"access_token": _access_token,
|
||||
"token_type": "bearer",
|
||||
"must_change_password": bool(user.get("must_change_password", False))
|
||||
"must_change_password": bool(_user.get("must_change_password", False))
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/change-password")
|
||||
async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
user: dict[str, Any] | None = await repo.get_user_by_uuid(current_user)
|
||||
if not user or not verify_password(request.old_password, user["password_hash"]):
|
||||
_user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user)
|
||||
if not _user or not verify_password(request.old_password, _user["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect old password",
|
||||
)
|
||||
|
||||
new_hash = get_password_hash(request.new_password)
|
||||
await repo.update_user_password(current_user, new_hash, must_change_password=False)
|
||||
_new_hash: str = get_password_hash(request.new_password)
|
||||
await repo.update_user_password(current_user, _new_hash, must_change_password=False)
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
|
||||
@@ -149,16 +149,16 @@ async def change_password(request: ChangePasswordRequest, current_user: str = De
|
||||
async def get_logs(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str | None = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: str = Depends(get_current_user)
|
||||
) -> dict[str, Any]:
|
||||
logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=search)
|
||||
total: int = await repo.get_total_logs(search=search)
|
||||
_logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=search)
|
||||
_total: int = await repo.get_total_logs(search=search)
|
||||
return {
|
||||
"total": total,
|
||||
"total": _total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"data": logs
|
||||
"data": _logs
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,20 +18,20 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
# Use a cost factor of 12 (default for passlib/bcrypt)
|
||||
salt = bcrypt.gensalt(rounds=12)
|
||||
hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
|
||||
return hashed.decode("utf-8")
|
||||
_salt: bytes = bcrypt.gensalt(rounds=12)
|
||||
_hashed: bytes = bcrypt.hashpw(password.encode("utf-8"), _salt)
|
||||
return _hashed.decode("utf-8")
|
||||
|
||||
|
||||
def create_access_token(data: dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode: dict[str, Any] = data.copy()
|
||||
expire: datetime
|
||||
_to_encode: dict[str, Any] = data.copy()
|
||||
_expire: datetime
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
_expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
_expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
to_encode.update({"iat": datetime.now(timezone.utc)})
|
||||
encoded_jwt: str = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
_to_encode.update({"exp": _expire})
|
||||
_to_encode.update({"iat": datetime.now(timezone.utc)})
|
||||
_encoded_jwt: str = jwt.encode(_to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return _encoded_jwt
|
||||
|
||||
@@ -7,62 +7,62 @@ from pathlib import Path
|
||||
|
||||
from decnet.web.repository import BaseRepository
|
||||
|
||||
logger = logging.getLogger("decnet.web.ingester")
|
||||
logger: logging.Logger = logging.getLogger("decnet.web.ingester")
|
||||
|
||||
async def log_ingestion_worker(repo: BaseRepository) -> None:
|
||||
"""
|
||||
Background task that tails the DECNET_INGEST_LOG_FILE.json and
|
||||
inserts structured JSON logs into the SQLite repository.
|
||||
"""
|
||||
base_log_file = os.environ.get("DECNET_INGEST_LOG_FILE")
|
||||
if not base_log_file:
|
||||
_base_log_file: str | None = os.environ.get("DECNET_INGEST_LOG_FILE")
|
||||
if not _base_log_file:
|
||||
logger.warning("DECNET_INGEST_LOG_FILE not set. Log ingestion disabled.")
|
||||
return
|
||||
|
||||
json_log_path = Path(base_log_file).with_suffix(".json")
|
||||
position = 0
|
||||
_json_log_path: Path = Path(_base_log_file).with_suffix(".json")
|
||||
_position: int = 0
|
||||
|
||||
logger.info(f"Starting JSON log ingestion from {json_log_path}")
|
||||
logger.info(f"Starting JSON log ingestion from {_json_log_path}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
if not json_log_path.exists():
|
||||
if not _json_log_path.exists():
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
stat = json_log_path.stat()
|
||||
if stat.st_size < position:
|
||||
_stat: os.stat_result = _json_log_path.stat()
|
||||
if _stat.st_size < _position:
|
||||
# File rotated or truncated
|
||||
position = 0
|
||||
_position = 0
|
||||
|
||||
if stat.st_size == position:
|
||||
if _stat.st_size == _position:
|
||||
# No new data
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
with open(json_log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(position)
|
||||
with open(_json_log_path, "r", encoding="utf-8", errors="replace") as _f:
|
||||
_f.seek(_position)
|
||||
while True:
|
||||
line = f.readline()
|
||||
if not line:
|
||||
_line: str = _f.readline()
|
||||
if not _line:
|
||||
break # EOF reached
|
||||
|
||||
if not line.endswith('\n'):
|
||||
if not _line.endswith('\n'):
|
||||
# Partial line read, don't process yet, don't advance position
|
||||
break
|
||||
|
||||
try:
|
||||
log_data = json.loads(line.strip())
|
||||
await repo.add_log(log_data)
|
||||
_log_data: dict[str, Any] = json.loads(_line.strip())
|
||||
await repo.add_log(_log_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to decode JSON log line: {line}")
|
||||
logger.error(f"Failed to decode JSON log line: {_line}")
|
||||
continue
|
||||
|
||||
# Update position after successful line read
|
||||
position = f.tell()
|
||||
_position = _f.tell()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in log ingestion worker: {e}")
|
||||
except Exception as _e:
|
||||
logger.error(f"Error in log ingestion worker: {_e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -10,9 +10,9 @@ class SQLiteRepository(BaseRepository):
|
||||
self.db_path: str = db_path
|
||||
|
||||
async def initialize(self) -> None:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
# Logs table
|
||||
await db.execute("""
|
||||
await _db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -26,15 +26,15 @@ class SQLiteRepository(BaseRepository):
|
||||
)
|
||||
""")
|
||||
try:
|
||||
await db.execute("ALTER TABLE logs ADD COLUMN fields TEXT")
|
||||
await _db.execute("ALTER TABLE logs ADD COLUMN fields TEXT")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
try:
|
||||
await db.execute("ALTER TABLE logs ADD COLUMN msg TEXT")
|
||||
await _db.execute("ALTER TABLE logs ADD COLUMN msg TEXT")
|
||||
except aiosqlite.OperationalError:
|
||||
pass
|
||||
# Users table (internal RBAC)
|
||||
await db.execute("""
|
||||
await _db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE,
|
||||
@@ -44,19 +44,19 @@ class SQLiteRepository(BaseRepository):
|
||||
)
|
||||
""")
|
||||
try:
|
||||
await db.execute("ALTER TABLE users ADD COLUMN must_change_password BOOLEAN DEFAULT 0")
|
||||
await _db.execute("ALTER TABLE users ADD COLUMN must_change_password BOOLEAN DEFAULT 0")
|
||||
except aiosqlite.OperationalError:
|
||||
pass # Column already exists
|
||||
await db.commit()
|
||||
await _db.commit()
|
||||
|
||||
async def add_log(self, log_data: dict[str, Any]) -> None:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
timestamp = log_data.get("timestamp")
|
||||
if timestamp:
|
||||
await db.execute(
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
_timestamp: Any = log_data.get("timestamp")
|
||||
if _timestamp:
|
||||
await _db.execute(
|
||||
"INSERT INTO logs (timestamp, decky, service, event_type, attacker_ip, raw_line, fields, msg) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
timestamp,
|
||||
_timestamp,
|
||||
log_data.get("decky"),
|
||||
log_data.get("service"),
|
||||
log_data.get("event_type"),
|
||||
@@ -67,7 +67,7 @@ class SQLiteRepository(BaseRepository):
|
||||
)
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
await _db.execute(
|
||||
"INSERT INTO logs (decky, service, event_type, attacker_ip, raw_line, fields, msg) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
log_data.get("decky"),
|
||||
@@ -79,7 +79,7 @@ class SQLiteRepository(BaseRepository):
|
||||
log_data.get("msg")
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await _db.commit()
|
||||
|
||||
async def get_logs(
|
||||
self,
|
||||
@@ -87,74 +87,74 @@ class SQLiteRepository(BaseRepository):
|
||||
offset: int = 0,
|
||||
search: Optional[str] = None
|
||||
) -> list[dict[str, Any]]:
|
||||
query: str = "SELECT * FROM logs"
|
||||
params: list[Any] = []
|
||||
_query: str = "SELECT * FROM logs"
|
||||
_params: list[Any] = []
|
||||
if search:
|
||||
query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
|
||||
like_val = f"%{search}%"
|
||||
params.extend([like_val, like_val, like_val, like_val])
|
||||
_query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
|
||||
_like_val: str = f"%{search}%"
|
||||
_params.extend([_like_val, _like_val, _like_val, _like_val])
|
||||
|
||||
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
_query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
||||
_params.extend([limit, offset])
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(query, params) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
_db.row_factory = aiosqlite.Row
|
||||
async with _db.execute(_query, _params) as _cursor:
|
||||
_rows: list[aiosqlite.Row] = await _cursor.fetchall()
|
||||
return [dict(_row) for _row in _rows]
|
||||
|
||||
async def get_total_logs(self, search: Optional[str] = None) -> int:
|
||||
query: str = "SELECT COUNT(*) as total FROM logs"
|
||||
params: list[Any] = []
|
||||
_query: str = "SELECT COUNT(*) as total FROM logs"
|
||||
_params: list[Any] = []
|
||||
if search:
|
||||
query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
|
||||
like_val = f"%{search}%"
|
||||
params.extend([like_val, like_val, like_val, like_val])
|
||||
_query += " WHERE raw_line LIKE ? OR decky LIKE ? OR service LIKE ? OR attacker_ip LIKE ?"
|
||||
_like_val: str = f"%{search}%"
|
||||
_params.extend([_like_val, _like_val, _like_val, _like_val])
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(query, params) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return row["total"] if row else 0
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
_db.row_factory = aiosqlite.Row
|
||||
async with _db.execute(_query, _params) as _cursor:
|
||||
_row: Optional[aiosqlite.Row] = await _cursor.fetchone()
|
||||
return _row["total"] if _row else 0
|
||||
|
||||
async def get_stats_summary(self) -> dict[str, Any]:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT COUNT(*) as total_logs FROM logs") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
total_logs: int = row["total_logs"] if row else 0
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
_db.row_factory = aiosqlite.Row
|
||||
async with _db.execute("SELECT COUNT(*) as total_logs FROM logs") as _cursor:
|
||||
_row: Optional[aiosqlite.Row] = await _cursor.fetchone()
|
||||
_total_logs: int = _row["total_logs"] if _row else 0
|
||||
|
||||
async with db.execute("SELECT COUNT(DISTINCT attacker_ip) as unique_attackers FROM logs") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
unique_attackers: int = row["unique_attackers"] if row else 0
|
||||
async with _db.execute("SELECT COUNT(DISTINCT attacker_ip) as unique_attackers FROM logs") as _cursor:
|
||||
_row = await _cursor.fetchone()
|
||||
_unique_attackers: int = _row["unique_attackers"] if _row else 0
|
||||
|
||||
async with db.execute("SELECT COUNT(DISTINCT decky) as active_deckies FROM logs") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
active_deckies: int = row["active_deckies"] if row else 0
|
||||
async with _db.execute("SELECT COUNT(DISTINCT decky) as active_deckies FROM logs") as _cursor:
|
||||
_row = await _cursor.fetchone()
|
||||
_active_deckies: int = _row["active_deckies"] if _row else 0
|
||||
|
||||
return {
|
||||
"total_logs": total_logs,
|
||||
"unique_attackers": unique_attackers,
|
||||
"active_deckies": active_deckies
|
||||
"total_logs": _total_logs,
|
||||
"unique_attackers": _unique_attackers,
|
||||
"active_deckies": _active_deckies
|
||||
}
|
||||
|
||||
async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
_db.row_factory = aiosqlite.Row
|
||||
async with _db.execute("SELECT * FROM users WHERE username = ?", (username,)) as _cursor:
|
||||
_row: Optional[aiosqlite.Row] = await _cursor.fetchone()
|
||||
return dict(_row) if _row else None
|
||||
|
||||
async def get_user_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute("SELECT * FROM users WHERE uuid = ?", (uuid,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
_db.row_factory = aiosqlite.Row
|
||||
async with _db.execute("SELECT * FROM users WHERE uuid = ?", (uuid,)) as _cursor:
|
||||
_row: Optional[aiosqlite.Row] = await _cursor.fetchone()
|
||||
return dict(_row) if _row else None
|
||||
|
||||
async def create_user(self, user_data: dict[str, Any]) -> None:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
await _db.execute(
|
||||
"INSERT INTO users (uuid, username, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
user_data["uuid"],
|
||||
@@ -164,12 +164,12 @@ class SQLiteRepository(BaseRepository):
|
||||
user_data.get("must_change_password", False)
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await _db.commit()
|
||||
|
||||
async def update_user_password(self, uuid: str, password_hash: str, must_change_password: bool = False) -> None:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
async with aiosqlite.connect(self.db_path) as _db:
|
||||
await _db.execute(
|
||||
"UPDATE users SET password_hash = ?, must_change_password = ? WHERE uuid = ?",
|
||||
(password_hash, must_change_password, uuid)
|
||||
)
|
||||
await db.commit()
|
||||
await _db.commit()
|
||||
|
||||
Reference in New Issue
Block a user