🚧 WIP
This commit is contained in:
+572
@@ -0,0 +1,572 @@
|
||||
"""Web UI for access control: users, groups, topic access rules.
|
||||
|
||||
Serves the login page, the admin pages (users, groups), and a telemetry
|
||||
browser so the effect of access rules can be verified directly. Sessions
|
||||
are shared with the API via the mqtt_session cookie, so a web login also
|
||||
authenticates API requests from the same browser.
|
||||
|
||||
User/group management is admin-only. Access-rule semantics (globs, deny
|
||||
wins over allow, user rules override group rules) live in shared/access.py.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Optional
|
||||
|
||||
import psycopg
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from psycopg import sql
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
import auth
|
||||
from shared.access import (
|
||||
COOKIE_NAME,
|
||||
Identity,
|
||||
escape_like,
|
||||
hash_token,
|
||||
load_identity,
|
||||
topic_conditions,
|
||||
)
|
||||
|
||||
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
|
||||
|
||||
_DictConnection = psycopg.Connection[dict[str, Any]]
|
||||
pool: Optional[_DictConnection] = None
|
||||
|
||||
|
||||
def _connect_kwargs() -> dict[str, Any]:
|
||||
return {
|
||||
"host": os.getenv("DB_HOST", "timescaledb"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "telemetry"),
|
||||
"user": os.getenv("DB_USER", "ingester"),
|
||||
"password": os.getenv("DB_PASSWORD", "changeme"),
|
||||
"row_factory": dict_row,
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global pool
|
||||
# Single long-lived autocommit connection; the web UI's load is tiny
|
||||
# compared to the API's, so a pool would be overkill.
|
||||
conn = psycopg.connect(**_connect_kwargs())
|
||||
pool = conn
|
||||
try:
|
||||
# First run: create the initial admin (ENV or default admin/admin).
|
||||
auth.bootstrap_admin(conn)
|
||||
yield
|
||||
finally:
|
||||
pool = None
|
||||
conn.close()
|
||||
|
||||
|
||||
app = FastAPI(title="MQTT Ingester Web UI", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
|
||||
|
||||
|
||||
def get_conn() -> _DictConnection:
|
||||
if pool is None:
|
||||
raise RuntimeError("Database connection not initialized")
|
||||
return pool
|
||||
|
||||
|
||||
def current_identity(request: Request) -> Optional[Identity]:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
return load_identity(get_conn(), hash_token(token))
|
||||
|
||||
|
||||
def require_login(request: Request) -> Identity:
|
||||
identity = current_identity(request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||
# A default/admin-set password must be changed before anything else.
|
||||
if identity.must_change_password and request.url.path not in (
|
||||
"/password",
|
||||
"/logout",
|
||||
):
|
||||
raise HTTPException(status_code=303, headers={"Location": "/password"})
|
||||
return identity
|
||||
|
||||
|
||||
def require_admin(request: Request) -> Identity:
|
||||
identity = require_login(request)
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return identity
|
||||
|
||||
|
||||
async def require_csrf(request: Request, identity: Identity) -> None:
|
||||
"""Validate the CSRF token for a mutating (form) request."""
|
||||
form = await request.form()
|
||||
submitted = form.get("csrf_token", "")
|
||||
if not isinstance(submitted, str) or not hmac.compare_digest(
|
||||
submitted, identity.csrf_token
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Invalid CSRF token")
|
||||
|
||||
|
||||
def _telemetry_rows(identity: Identity, topic: Optional[str]) -> list[dict[str, Any]]:
|
||||
"""Recent telemetry filtered by the identity's access rules in SQL."""
|
||||
conditions, param_groups = topic_conditions(identity)
|
||||
params: list[Any] = []
|
||||
if topic:
|
||||
conditions.append(sql.SQL("topic LIKE %s"))
|
||||
params.append(escape_like(topic))
|
||||
params.extend(p for group in param_groups for p in group)
|
||||
query = sql.SQL("SELECT time, topic, payload FROM telemetry")
|
||||
if conditions:
|
||||
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
|
||||
query += sql.SQL(" ORDER BY time DESC LIMIT 100")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
# -- Login / logout ---------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
if current_identity(request) is None:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return RedirectResponse("/data", status_code=303)
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: Optional[str] = None):
|
||||
if current_identity(request) is not None:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return templates.TemplateResponse(request, "login.html", {"error": error})
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||
conn = get_conn()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
allowed, attempts = auth.check_throttle(conn, ip)
|
||||
if not allowed:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{"error": "Too many failed attempts; try again in a few minutes."},
|
||||
status_code=429,
|
||||
)
|
||||
with conn.cursor() as cur:
|
||||
row = cur.execute(
|
||||
"SELECT id, password_hash FROM users"
|
||||
" WHERE username = %s AND disabled_at IS NULL",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if row is None or not auth.verify_password(password, row[1]):
|
||||
auth.record_failed_login(conn, ip)
|
||||
return templates.TemplateResponse(
|
||||
request, "login.html", {"error": "Invalid username or password"}, status_code=401
|
||||
)
|
||||
auth.clear_failed_logins(conn, ip)
|
||||
cookie_token, _csrf = auth.create_session(conn, row[0])
|
||||
resp = RedirectResponse("/data", status_code=303)
|
||||
auth.set_cookie(resp, cookie_token)
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
async def logout(request: Request):
|
||||
identity = current_identity(request)
|
||||
if identity is not None:
|
||||
await require_csrf(request, identity)
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
if token:
|
||||
auth.delete_session(get_conn(), token)
|
||||
resp = RedirectResponse("/login", status_code=303)
|
||||
auth.clear_cookie(resp)
|
||||
return resp
|
||||
|
||||
|
||||
# -- Account (self-service) --------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/password", response_class=HTMLResponse)
|
||||
async def password_page(request: Request, error: Optional[str] = None):
|
||||
identity = require_login(request)
|
||||
return templates.TemplateResponse(
|
||||
request, "password.html", {"identity": identity, "error": error}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/password")
|
||||
async def password_change(
|
||||
request: Request,
|
||||
current_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
confirm_password: str = Form(...),
|
||||
):
|
||||
identity = require_login(request)
|
||||
await require_csrf(request, identity)
|
||||
conn = get_conn()
|
||||
|
||||
def fail(message: str, status: int):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"password.html",
|
||||
{"identity": identity, "error": message},
|
||||
status_code=status,
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
row = cur.execute(
|
||||
"SELECT password_hash FROM users WHERE id = %s", (identity.user_id,)
|
||||
).fetchone()
|
||||
if row is None or not auth.verify_password(current_password, row[0]):
|
||||
return fail("Current password is incorrect", 401)
|
||||
if new_password != confirm_password:
|
||||
return fail("New passwords do not match", 400)
|
||||
if len(new_password) < 8:
|
||||
return fail("New password must be at least 8 characters", 400)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET password_hash = %s, must_change_password = FALSE"
|
||||
" WHERE id = %s",
|
||||
(auth.hash_password(new_password), identity.user_id),
|
||||
)
|
||||
return RedirectResponse("/data", status_code=303)
|
||||
|
||||
|
||||
# -- Users (admin) -----------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def users_page(request: Request):
|
||||
identity = require_admin(request)
|
||||
with get_conn().cursor() as cur:
|
||||
users = cur.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.role, u.disabled_at, u.must_change_password,
|
||||
coalesce(
|
||||
(SELECT array_agg(g.name ORDER BY g.name)
|
||||
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
|
||||
WHERE ug.user_id = u.id), '{}') AS groups
|
||||
FROM users u ORDER BY u.username
|
||||
"""
|
||||
).fetchall()
|
||||
groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall()
|
||||
return templates.TemplateResponse(
|
||||
request, "users.html", {"identity": identity, "users": users, "groups": groups}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/users")
|
||||
async def create_user(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
role: str = Form("user"),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
conn = get_conn()
|
||||
username = username.strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="Username is required")
|
||||
if role not in ("user", "admin"):
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
with conn.cursor() as cur:
|
||||
try:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash, role) VALUES (%s, %s, %s)",
|
||||
(username, auth.hash_password(password), role),
|
||||
)
|
||||
except psycopg.errors.UniqueViolation:
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
if group_ids:
|
||||
user_id = cur.execute(
|
||||
"SELECT id FROM users WHERE username = %s", (username,)
|
||||
).fetchone()[0]
|
||||
cur.executemany(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
|
||||
[(user_id, int(gid)) for gid in group_ids],
|
||||
)
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.get("/users/{user_id}", response_class=HTMLResponse)
|
||||
async def user_detail(request: Request, user_id: int):
|
||||
identity = require_admin(request)
|
||||
with get_conn().cursor() as cur:
|
||||
user = cur.execute(
|
||||
"SELECT id, username, role, disabled_at, must_change_password"
|
||||
" FROM users WHERE id = %s",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
rules = cur.execute(
|
||||
"SELECT id, kind, pattern FROM access_rules WHERE user_id = %s"
|
||||
" ORDER BY kind, pattern",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
member_of = cur.execute(
|
||||
"SELECT g.id, g.name FROM groups g"
|
||||
" JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = %s",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
all_groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"user_detail.html",
|
||||
{
|
||||
"identity": identity,
|
||||
"user": user,
|
||||
"rules": rules,
|
||||
"member_of": member_of,
|
||||
"all_groups": all_groups,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/password")
|
||||
async def set_user_password(request: Request, user_id: int, password: str = Form(...)):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET password_hash = %s, must_change_password = TRUE"
|
||||
" WHERE id = %s",
|
||||
(auth.hash_password(password), user_id),
|
||||
)
|
||||
cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/groups")
|
||||
async def set_user_groups(
|
||||
request: Request, user_id: int, group_ids: list[str] = Form(default=[])
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
|
||||
if group_ids:
|
||||
cur.executemany(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
|
||||
[(user_id, int(gid)) for gid in group_ids],
|
||||
)
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/disable")
|
||||
async def disable_user(request: Request, user_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE users SET disabled_at = now() WHERE id = %s AND disabled_at IS NULL",
|
||||
(user_id,),
|
||||
)
|
||||
cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,))
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/enable")
|
||||
async def enable_user(request: Request, user_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("UPDATE users SET disabled_at = NULL WHERE id = %s", (user_id,))
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/delete")
|
||||
async def delete_user(request: Request, user_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
if user_id == identity.user_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/rules")
|
||||
async def add_user_rule(
|
||||
request: Request, user_id: int, kind: str = Form(...), pattern: str = Form(...)
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
if kind not in ("allow", "deny"):
|
||||
raise HTTPException(status_code=400, detail="Invalid kind")
|
||||
pattern = pattern.strip()
|
||||
if not pattern:
|
||||
raise HTTPException(status_code=400, detail="Pattern is required")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO access_rules (user_id, kind, pattern) VALUES (%s, %s, %s)",
|
||||
(user_id, kind, pattern),
|
||||
)
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/users/{user_id}/rules/{rule_id}/delete")
|
||||
async def delete_user_rule(request: Request, user_id: int, rule_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM access_rules WHERE id = %s AND user_id = %s", (rule_id, user_id)
|
||||
)
|
||||
return RedirectResponse(f"/users/{user_id}", status_code=303)
|
||||
|
||||
|
||||
# -- Groups (admin) -----------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/groups", response_class=HTMLResponse)
|
||||
async def groups_page(request: Request):
|
||||
identity = require_admin(request)
|
||||
with get_conn().cursor() as cur:
|
||||
groups = cur.execute(
|
||||
"""
|
||||
SELECT g.id, g.name,
|
||||
(SELECT count(*) FROM user_groups ug WHERE ug.group_id = g.id)
|
||||
AS member_count,
|
||||
(SELECT count(*) FROM access_rules ar WHERE ar.group_id = g.id)
|
||||
AS rule_count
|
||||
FROM groups g ORDER BY g.name
|
||||
"""
|
||||
).fetchall()
|
||||
return templates.TemplateResponse(
|
||||
request, "groups.html", {"identity": identity, "groups": groups}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/groups")
|
||||
async def create_group(request: Request, name: str = Form(...)):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Group name is required")
|
||||
with get_conn().cursor() as cur:
|
||||
try:
|
||||
cur.execute("INSERT INTO groups (name) VALUES (%s)", (name,))
|
||||
except psycopg.errors.UniqueViolation:
|
||||
raise HTTPException(status_code=409, detail="Group name already exists")
|
||||
return RedirectResponse("/groups", status_code=303)
|
||||
|
||||
|
||||
@app.get("/groups/{group_id}", response_class=HTMLResponse)
|
||||
async def group_detail(request: Request, group_id: int):
|
||||
identity = require_admin(request)
|
||||
with get_conn().cursor() as cur:
|
||||
group = cur.execute(
|
||||
"SELECT id, name FROM groups WHERE id = %s", (group_id,)
|
||||
).fetchone()
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
rules = cur.execute(
|
||||
"SELECT id, kind, pattern FROM access_rules WHERE group_id = %s"
|
||||
" ORDER BY kind, pattern",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
members = cur.execute(
|
||||
"SELECT u.id, u.username FROM users u"
|
||||
" JOIN user_groups ug ON ug.user_id = u.id WHERE ug.group_id = %s",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
all_users = cur.execute(
|
||||
"SELECT id, username FROM users ORDER BY username"
|
||||
).fetchall()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"group_detail.html",
|
||||
{
|
||||
"identity": identity,
|
||||
"group": group,
|
||||
"rules": rules,
|
||||
"members": members,
|
||||
"all_users": all_users,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/groups/{group_id}/rules")
|
||||
async def add_group_rule(
|
||||
request: Request, group_id: int, kind: str = Form(...), pattern: str = Form(...)
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
if kind not in ("allow", "deny"):
|
||||
raise HTTPException(status_code=400, detail="Invalid kind")
|
||||
pattern = pattern.strip()
|
||||
if not pattern:
|
||||
raise HTTPException(status_code=400, detail="Pattern is required")
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO access_rules (group_id, kind, pattern) VALUES (%s, %s, %s)",
|
||||
(group_id, kind, pattern),
|
||||
)
|
||||
return RedirectResponse(f"/groups/{group_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/groups/{group_id}/rules/{rule_id}/delete")
|
||||
async def delete_group_rule(request: Request, group_id: int, rule_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM access_rules WHERE id = %s AND group_id = %s",
|
||||
(rule_id, group_id),
|
||||
)
|
||||
return RedirectResponse(f"/groups/{group_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/groups/{group_id}/members")
|
||||
async def set_group_members(
|
||||
request: Request, group_id: int, user_ids: list[str] = Form(default=[])
|
||||
):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("DELETE FROM user_groups WHERE group_id = %s", (group_id,))
|
||||
if user_ids:
|
||||
cur.executemany(
|
||||
"INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)",
|
||||
[(int(uid), group_id) for uid in user_ids],
|
||||
)
|
||||
return RedirectResponse(f"/groups/{group_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/groups/{group_id}/delete")
|
||||
async def delete_group(request: Request, group_id: int):
|
||||
identity = require_admin(request)
|
||||
await require_csrf(request, identity)
|
||||
with get_conn().cursor() as cur:
|
||||
cur.execute("DELETE FROM groups WHERE id = %s", (group_id,))
|
||||
return RedirectResponse("/groups", status_code=303)
|
||||
|
||||
|
||||
# -- Data browser -------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/data", response_class=HTMLResponse)
|
||||
async def data_page(request: Request, topic: Optional[str] = None):
|
||||
identity = require_login(request)
|
||||
rows = _telemetry_rows(identity, topic)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"data.html",
|
||||
{"identity": identity, "rows": rows, "topic": topic or ""},
|
||||
)
|
||||
Reference in New Issue
Block a user