Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 208 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Copy link
Copy Markdown

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.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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);
"""


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -1594,6 +1617,16 @@
(new_status, suspended_until, user_id)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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,))
Expand All @@ -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)):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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"]

Check failure on line 1752 in backend/main.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

backend/main.py#L1752

SQL Injection is a critical vulnerability that can lead to data or system compromise.

# 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()

Check failure on line 1764 in backend/main.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

backend/main.py#L1764

SQL Injection is a critical vulnerability that can lead to data or system compromise.

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")
Expand Down
Loading
Loading