diff --git a/backend/main.py b/backend/main.py index 7f55e9d..e1a496b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -235,6 +235,29 @@ ); CREATE INDEX IF NOT EXISTS idx_group_invites_invitee ON group_invites(invitee_id, expires_at); CREATE INDEX IF NOT EXISTS idx_group_invites_group ON group_invites(group_id); +CREATE TABLE IF NOT EXISTS mod_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mod_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + action TEXT NOT NULL, + reason TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mod_actions_mod ON mod_actions(mod_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_mod_actions_target ON mod_actions(target_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_mod_actions_time ON mod_actions(created_at DESC); +CREATE TABLE IF NOT EXISTS appeals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + reason TEXT NOT NULL, + response TEXT, + created_at INTEGER NOT NULL, + reviewed_at INTEGER, + reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_appeals_user ON appeals(user_id); +CREATE INDEX IF NOT EXISTS idx_appeals_status ON appeals(status, created_at DESC); """ @@ -477,7 +500,7 @@ class ReportIn(BaseModel): reportedUserId: Optional[int] = None messageId: Optional[int] = None groupMessageId: Optional[int] = None - reason: str = Field(min_length=5, max_length=500) + reason: str = Field(min_length=1, max_length=500) details: Optional[str] = Field(default=None, max_length=2000) @@ -1594,6 +1617,16 @@ def mod_user_status(user_id: int, body: ModUserActionIn, user = Depends(require_ (new_status, suspended_until, user_id) ) + # Log action to audit log + action_str = body.action + if body.action == "suspend" and body.duration_days: + action_str = f"suspend_{body.duration_days}d" + + conn.execute( + "INSERT INTO mod_actions (mod_id, target_id, action, reason, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], user_id, action_str, body.reason, now) + ) + # Revoke sessions if suspending or banning if new_status in ("banned", "suspended"): conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) @@ -1608,15 +1641,189 @@ def mod_change_role(user_id: int, body: ModChangeRoleIn, user = Depends(require_ if not is_super_moderator(user): raise HTTPException(403, "Only super moderators can change roles") + now = int(time.time()) with db() as conn: target = conn.execute("SELECT id, role FROM users WHERE id = ?", (user_id,)).fetchone() if not target: raise HTTPException(404, "User not found") conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.new_role, user_id)) + # Log action to audit log + conn.execute( + "INSERT INTO mod_actions (mod_id, target_id, action, reason, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], user_id, f"role_change_{body.new_role}", f"Changed from {target['role']}", now) + ) + return {"ok": True, "newRole": body.new_role} +@app.get("/api/mod/audit-log") +def get_audit_log(user = Depends(require_moderator), limit: int = 100, offset: int = 0): + """Get audit log of moderator actions. Limited to 100 entries by default.""" + if limit > 500: + limit = 500 + if limit < 1: + limit = 1 + if offset < 0: + offset = 0 + + with db() as conn: + # Get total count + total = conn.execute("SELECT COUNT(*) as cnt FROM mod_actions").fetchone()["cnt"] + + # Get audit log entries ordered by most recent first + rows = conn.execute( + """SELECT ma.id, ma.mod_id, ma.target_id, ma.action, ma.reason, ma.created_at, + mod_user.username as mod_username, + target_user.username as target_username + FROM mod_actions ma + LEFT JOIN users mod_user ON ma.mod_id = mod_user.id + LEFT JOIN users target_user ON ma.target_id = target_user.id + ORDER BY ma.created_at DESC + LIMIT ? OFFSET ?""", + (limit, offset) + ).fetchall() + + entries = [ + { + "id": r["id"], + "modId": r["mod_id"], + "modUsername": r["mod_username"], + "targetId": r["target_id"], + "targetUsername": r["target_username"], + "action": r["action"], + "reason": r["reason"], + "createdAt": r["created_at"] + } + for r in rows + ] + + return { + "total": total, + "limit": limit, + "offset": offset, + "entries": entries + } + + +@app.post("/api/appeals") +def create_appeal(body: ModUserActionIn, user = Depends(auth_dep)): + """Allow suspended/banned users to appeal their status.""" + now = int(time.time()) + with db() as conn: + user_row = conn.execute("SELECT id, status FROM users WHERE id = ?", (user["id"],)).fetchone() + if user_row["status"] == "active": + raise HTTPException(400, "Only suspended or banned users can appeal") + + # Check if user already has a pending appeal + existing = conn.execute( + "SELECT id FROM appeals WHERE user_id = ? AND status = 'pending'", + (user["id"],) + ).fetchone() + if existing: + raise HTTPException(400, "You already have a pending appeal") + + # Create appeal + conn.execute( + "INSERT INTO appeals (user_id, status, reason, created_at) VALUES (?, ?, ?, ?)", + (user["id"], "pending", body.reason, now) + ) + + return {"ok": True, "message": "Appeal submitted successfully"} + + +@app.get("/api/mod/appeals") +def get_appeals(user = Depends(require_moderator), status: str = "pending", limit: int = 50, offset: int = 0): + """Get appeals for moderator review.""" + if limit > 500: + limit = 500 + if limit < 1: + limit = 1 + if offset < 0: + offset = 0 + + with db() as conn: + # Get total count + query = "SELECT COUNT(*) as cnt FROM appeals" + params = [] + if status: + query += " WHERE status = ?" + params.append(status) + total = conn.execute(query, params).fetchone()["cnt"] + + # Get appeals + query = """SELECT a.id, a.user_id, a.status, a.reason, a.response, a.created_at, a.reviewed_at, + a.reviewed_by, u.email + FROM appeals a + LEFT JOIN users u ON a.user_id = u.id""" + if status: + query += " WHERE a.status = ?" + query += " ORDER BY a.created_at DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + rows = conn.execute(query, params).fetchall() + + entries = [ + { + "id": r["id"], + "userId": r["user_id"], + "userEmail": r["email"], + "status": r["status"], + "reason": r["reason"], + "response": r["response"], + "createdAt": r["created_at"], + "reviewedAt": r["reviewed_at"] + } + for r in rows + ] + + return { + "total": total, + "limit": limit, + "offset": offset, + "entries": entries + } + + +@app.post("/api/mod/appeals/{appeal_id}") +def review_appeal(appeal_id: int, body: ModUserActionIn, user = Depends(require_moderator)): + """Review and approve/reject an appeal.""" + if body.action not in ("approve", "reject"): + raise HTTPException(400, "Action must be 'approve' or 'reject'") + + now = int(time.time()) + with db() as conn: + appeal = conn.execute("SELECT user_id, status FROM appeals WHERE id = ?", (appeal_id,)).fetchone() + if not appeal: + raise HTTPException(404, "Appeal not found") + if appeal["status"] != "pending": + raise HTTPException(400, "Appeal already reviewed") + + if body.action == "approve": + # Restore user + conn.execute( + "UPDATE users SET status = 'active', suspended_until = NULL WHERE id = ?", + (appeal["user_id"],) + ) + new_status = "approved" + else: + new_status = "rejected" + + # Update appeal + conn.execute( + "UPDATE appeals SET status = ?, response = ?, reviewed_at = ?, reviewed_by = ? WHERE id = ?", + (new_status, body.reason, now, user["id"], appeal_id) + ) + + # Log action + conn.execute( + "INSERT INTO mod_actions (mod_id, target_id, action, reason, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], appeal["user_id"], f"appeal_{new_status}", body.reason, now) + ) + + return {"ok": True, "status": new_status} + + # ── Session management ──────────────────────────────────────────────────────────── @app.post("/api/auth/logout-all") diff --git a/static/index.html b/static/index.html index 0811c2f..b603f8a 100644 --- a/static/index.html +++ b/static/index.html @@ -356,21 +356,53 @@

Danger zone

Suspended
0
Banned
0
Messages
0
-
Reports
0
+
+
Reports
+
0
+ +
+
+
Appeals
+
0
+ +

Recent reports

Reports are shown in plain text so moderators can review them quickly.
+

User accounts

Suspend or ban an account to stop abusive activity.

+
+ + +
+ +
+

Pending appeals

+

Users can appeal their suspension or ban with a written reason.

+
+
+ +
+

Audit log

+

All moderator actions are logged for transparency and accountability.

+
+
@@ -501,6 +533,113 @@

Start something new

+ + + + + + + +