-
Notifications
You must be signed in to change notification settings - Fork 1
feat: comprehensive moderator enhancements and fixes #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e53cc26
e4e87d0
066fa29
054d8d9
6e03c9d
7a8a3e4
76be489
f17fa9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Comment on lines
+240
to
+241
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM RISK Suggestion: Audit logs should persist even if a user is deleted. Change 'ON DELETE CASCADE' to 'ON DELETE SET NULL' for 'mod_id' and 'target_id' columns to maintain historical integrity. |
||
| 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 @@ | |
| 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 @@ | |
| (new_status, suspended_until, user_id) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM RISK Implementation for auto-restoring users at login is missing. The 'suspended_until' column is populated, but there is no logic in the auth flow to check or clear this status upon login. |
||
| ) | ||
|
|
||
| # 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 @@ | |
| 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)): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 HIGH RISK Suspended and banned users cannot submit appeals because their sessions are revoked upon status change. Since '/api/mod/appeals' requires authentication, these users are locked out. Consider a restricted session state or public submission endpoint with token verification. |
||
| """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") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 MEDIUM RISK
Missing implementation for Issue #16. The report submission endpoint still uses default validation and does not permit 1-character reasons.