Fix appeal submission for suspended users without session#22
Conversation
- Replace unsafe innerHTML assignments with safe DOM manipulation (createElement, appendChild, textContent) - Fix moderation reports list rendering (line 748-785) - Fix moderation users list rendering (line 787-843) - Add try-catch error handling to loadModeration() function - Add error handling to async user action event listeners - Use textContent for user-controlled data instead of innerHTML templates Fixes XSS issues reported in static/js/app.js related to reports and users lists
- Replace unsafe innerHTML assignments with replaceChildren() - Use textContent instead of innerHTML for error messages - Eliminate unencoded HTML in error display - Improve XSS vulnerability prevention
…e checks, and session revocation - Fix CSRF protection in require_active_user and require_moderator dependencies - Add moderator privilege escalation prevention in mod_user_status - Extend session revocation to suspended users (not just banned) - Sync moderator tab visibility on boot() for existing sessions
- Fix toast message grammar for account actions (ban->banned, suspend->suspended, restore->restored) - Prevent suspended users from accessing group messages by using require_active_user
- New super_moderator role with higher privileges than moderators
- Regular moderators cannot ban/suspend other moderators
- Suspension system with duration (1-365 days) and auto-restore on expiry
- Added suspended_until column with migration
- Fixed suspended user login blocking
- New /api/mod/users/{id}/role endpoint for role management
- Complete HTML injection audit: all user data properly escaped
- Improved role-based access controls throughout
Fixes privilege escalation where moderators could ban each other.
- Add mod_actions table with mod_id, target_id, action, reason, and created_at - Create indexes on mod_id, target_id, and created_at for efficient queries - Log all moderator actions (suspend, ban, restore, role changes) to audit table - Add GET /api/mod/audit-log endpoint to retrieve audit logs with pagination - Add audit log UI section to moderation dashboard - Display audit entries showing action, moderator, target, reason, and timestamp https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add suspension duration modal with predefined options (1, 7, 30, 60 days) - Add optional reason field when suspending accounts - Update moderator action handler to use suspensionDialog for suspension actions - Store suspension duration and reason in database for audit trail - Allow moderators to specify why account is being suspended https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add search input to filter users by email - Add status filter dropdown (active, suspended, banned, moderators) - Implement real-time filtering as user types - Refactor user list rendering to support dynamic filtering - Users can quickly find and take bulk actions on specific users https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add appeals table to track user appeals with status, reason, and moderator response
- Create POST /api/appeals endpoint to allow users to submit appeals
- Create GET /api/mod/appeals endpoint for moderators to review pending appeals
- Create POST /api/mod/appeals/{id} endpoint to approve/reject appeals
- Add appeals UI section to moderation dashboard
- Add appeal review modal with decision tracking
- Add submit appeal modal for restricted users
- Auto-restore users when appeal is approved
https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add unreviewed reports and pending appeals metric cards - Display badge on appeals card showing count of pending appeals - Automatically update appeal count when moderation dashboard loads - Color-coded badges (red) for quick visual indication of pending work https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add search input to filter reports by keyword - Search across reason, details, reporter email, and reported user email - Real-time filtering as moderators type - Shows 'No matching reports' when search yields no results https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add predefined reason templates for suspension, ban, and restore actions - Templates include: spam, harassment, inappropriate content, guidelines violation, suspicious activity - Allow custom reason input in addition to templates - Reason templates auto-fill when moderators select from dropdown - Consistent UX across all moderator actions https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Reduce report reason minimum length from 5 to 1 character - Allows preset reasons like 'spam' (4 chars) to be submitted - Fixes validation error when reporting with short reason text https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Fix event listener accumulation in loadModeration() using event delegation - Add offset pagination parameter to mod_reports and mod_users endpoints - Add reason field to ModUserActionIn for logging moderator actions - Add super_moderator indicator in user list with orange styling - Update moderator filter to include both moderator and super_moderator roles - Apply require_active_user to report endpoint for consistency - Add comprehensive documentation for CIPHER_MODERATOR_EMAILS environment variable - Fix filter logic to correctly match super_moderators in list Fixes: - P0: Event listener accumulation (now uses event delegation) - P1: Pagination support (added offset to mod_reports and mod_users) - P1: Super moderator visibility (added orange indicator) - P1: Reason field validation (added to ModUserActionIn) - P1: Report submission protection (uses require_active_user) - P1: Environment variable documentation (added comments) https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
Changed entry.modUsername/targetUsername to entry.mod_email/target_email to match API response format.
…stead Restore now has its own flow with a simple prompt for restoration reason, helping other mods understand the context without the formal dialog format of ban actions.
Add a dedicated restore dialog with three preset reasons: - Appeal approved - Manual review - no violation found - Mistaken suspension Reason is now required for restore actions to help other mods understand context.
- Add warning message explaining message will be decrypted and sent unencrypted to mods - Show preview of message being reported in report dialog - Add confirmation dialog before submitting report - Decrypt message using user's key and send decrypted content with report - Store message_content in reports table - Display message content in mod reports view - Add migration for message_content column
Security Fixes: - CRITICAL: Fix SQL injection in get_appeals endpoint by validating status parameter - Validate status against whitelist (pending, approved, rejected) - Use parameterized queries consistently without dynamic query building - Prevent malicious SQL injection via status parameter - HIGH: Fix variable redeclaration of searchInput - Rename reports search input to reportsSearchInput - Prevents confusion and variable shadowing - HIGH: Fix JavaScript linting issues - Update event listener to use renamed variable The SQL injection vulnerability was in the dynamic query building for the appeals endpoint where the status parameter could be used maliciously. Now uses explicit queries for each case with proper parameter binding. https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Parallelize stats, appeals, reports, and users API calls in loadModeration using Promise.all for faster dashboard load - Add exception chaining to hasher.verify error handlers for better debugging traceback information https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
Indent lines 850+ to be properly nested inside try block https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
The inline script was blocked by Content-Security-Policy directive 'script-src self'. Moving service worker registration to /static/js/sw-register.js resolves the CSP violation. https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add Service-Worker-Allowed header to allow SW scope of / - Remove explicit scope from SW registration (uses SW directory scope by default) - These fixes resolve SW registration error and allow offline support https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
The loadModeration function was missing its closing brace, causing the entire app.js module to fail with 'Unexpected end of input' error. This prevented the app from loading at all - no event listeners attached, no login functionality, no UI initialization.
- Remove 'Moderation' from main tabs (was visually overflowing) - Add 'mod' toggle button in topbar-end (visible only to moderators) - Add separate mod-tabs nav with Dashboard/Reports/Users/Appeals/Audit - Split monolithic moderation pane into 5 sub-panes controlled by setModRoute - Fix usersList scoping bug: move declaration outside renderUsersList so event delegation can access it - Support legacy #moderation hash by routing to enterModMode
- Remove 'Moderation' from main tabs (was visually overflowing) - Add 'mod' toggle button in topbar-end (visible only to moderators) - Add separate mod-tabs nav with Dashboard/Reports/Users/Appeals/Audit - Split monolithic moderation pane into 5 sub-panes controlled by setModRoute - Fix usersList scoping bug: move declaration outside renderUsersList so event delegation can access it - Support legacy #moderation hash by routing to enterModMode
- Item 1: Message decryption for reports ✅ - Item 7: Restore dialog with preset reasons ✅ https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add AppealSubmitIn model with appeal_type, email confirmation, min 50 char reason - Add appeal_type column migration and endpoint support - Add GET /api/appeals/status endpoint for users to check appeal status - Modify POST /api/appeals to validate email and accept appeal_type - Update login endpoint to return restriction info (banned/suspended) instead of throwing errors - Return timeRemaining for suspended accounts to display on suspension screen - Support auto-restore on appeal approval https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
- Add suspension screen (replaces login when user is banned/suspended) - Show suspension time remaining with readable format - Add appeal form page with: - Appeal type dropdown (mistaken, violated by mistake, circumstances changed) - Email confirmation field - Reason textarea (min 50 char, max 2000) - Character count display - Handle appeal submission and show success screen - Route to /appeal from suspension screen - Update login flow to detect restrictions and show suspension screen https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
The suspension and appeal views weren't being toggled by setView(), causing blank screen when user is suspended. Now properly shows suspension screen with appeal option. https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
Suspended/banned users can't submit appeals through authenticated endpoints since they never received a session. Modified the appeal endpoint to verify credentials directly using email + authHash (matching login flow), and stored the authHash from login in state for use during appeal submission. Changes: - Add authHash field to AppealSubmitIn request model - Remove auth_dep requirement from POST /api/appeals - Verify credentials directly in appeal endpoint using hasher.verify - Store authHash in state when user is restricted - Include authHash in appeal form submission
📝 WalkthroughWalkthroughThis PR implements a complete moderation and account restriction system. Users now have roles (moderator/super-moderator) and statuses (active/suspended/banned), with expired suspensions auto-restoring on login. Login detects restrictions and returns metadata; appeal forms and moderation dashboards provide UI for restricted users and moderators. Sensitive operations require active accounts. Moderators can manage users, review reports with decrypted content, and approve/reject appeals via audit-logged endpoints. ChangesModeration and Account Restriction System
Sequence DiagramssequenceDiagram
participant RestrictedUser as Restricted User
participant LoginEndpoint as Login Endpoint
participant Database
participant ClientApp as Client App
participant SuspensionView as Suspension View
RestrictedUser->>LoginEndpoint: email + password
LoginEndpoint->>Database: verify user
alt suspended_until expired
Database->>Database: auto-restore to active
LoginEndpoint-->>ClientApp: {restricted: false, ...}
else status = suspended
LoginEndpoint-->>ClientApp: {restricted: true, restrictionType: suspended, timeRemaining: ms}
ClientApp->>SuspensionView: render with countdown
SuspensionView-->>RestrictedUser: display remaining time
else status = banned
LoginEndpoint-->>ClientApp: {restricted: true, restrictionType: banned}
ClientApp->>SuspensionView: render ban message
SuspensionView-->>RestrictedUser: offer appeal or sign out
end
sequenceDiagram
participant Moderator
participant ModDashboard as Mod Dashboard
participant ModAPI as Mod API
participant Database
Moderator->>ModDashboard: enter mod mode
ModDashboard->>ModAPI: fetch stats, reports, users, appeals
ModAPI->>Database: query tables
Database-->>ModAPI: data
ModAPI-->>ModDashboard: metrics + lists
ModDashboard-->>Moderator: render dashboard tiles + tables
Moderator->>ModDashboard: select user, click suspend
ModDashboard->>ModDashboard: show suspension modal
Moderator->>ModDashboard: enter duration + reason
ModDashboard->>ModAPI: POST /api/mod/users/{id}/status
ModAPI->>Database: update users.status, insert mod_actions
Database-->>ModAPI: success
ModAPI-->>ModDashboard: updated user
ModDashboard-->>Moderator: refresh user list
sequenceDiagram
participant User
participant ReportModal as Report Modal
participant Decryption as Decryption Engine
participant ReportAPI as Report API
User->>ReportModal: open modal for message
ReportModal->>Decryption: decrypt message content
Decryption-->>ReportModal: plaintext (or failure)
ReportModal->>ReportModal: display preview + privacy notice
User->>ReportModal: submit report
ReportModal->>ReportModal: confirm via modal
User->>ReportModal: confirm submission
ReportModal->>ReportAPI: POST /api/report {messageContent, reason, ...}
ReportAPI->>ReportAPI: store decrypted content + log
ReportAPI-->>ReportModal: success
ReportModal-->>User: close + toast
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 9 high |
| Security | 1 high |
🟢 Metrics 91 complexity · 0 duplication
Metric Results Complexity 91 Duplication 0
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Pull Request Overview
This Pull Request introduces a significant amount of logic beyond the stated goal of fixing appeal submissions, implementing a full moderation suite. Codacy analysis indicates the PR is not up to standards due to 10 new quality issues and a substantial increase in complexity without corresponding test coverage.
Critical issues that must be addressed before merging include a broken database schema for fresh installs, lack of rate limiting on the new credential-verification endpoint, and a logic flaw in user status transitions. Furthermore, the implementation of session-less appeals currently prevents banned users from checking their appeal status, which contradicts the functional intent of the feature.
About this PR
- The 'get_appeal_status' endpoint is currently protected by 'require_user', which blocks banned users. This prevents the target audience from verifying if their appeal has been received or processed.
- This PR contains significant scope creep. The addition of a complete moderation system should ideally be handled in a separate PR to allow for more focused review and testing.
- The new reporting flow decrypts message content on the client and sends it to the server. This represents a departure from the platform's zero-knowledge guarantee for these specific interactions.
Test suggestions
- Submit appeal as a suspended user with correct email and authHash (password) results in 200 OK.
- Submit appeal as a banned user with correct credentials results in 200 OK.
- Submit appeal with incorrect authHash results in 401 Unauthorized.
- Active user attempting to submit an appeal results in 400 Bad Request.
- Submitting a second appeal while one is already pending results in 400 Bad Request.
- Moderator approving an appeal updates user status to 'active' and revokes suspension expiry.
- Verify that banning or suspending a user via the moderator API successfully revokes all their active sessions.
- Unit tests for user status transition logic in backend/main.py.
- Automated verification of the session-less credential check in the appeals endpoint.
Prompt proposal for missing tests
Consider implementing these tests if applicable:
1. Submit appeal as a suspended user with correct email and authHash (password) results in 200 OK.
2. Submit appeal as a banned user with correct credentials results in 200 OK.
3. Submit appeal with incorrect authHash results in 401 Unauthorized.
4. Active user attempting to submit an appeal results in 400 Bad Request.
5. Submitting a second appeal while one is already pending results in 400 Bad Request.
6. Moderator approving an appeal updates user status to 'active' and revokes suspension expiry.
7. Verify that banning or suspending a user via the moderator API successfully revokes all their active sessions.
8. Unit tests for user status transition logic in backend/main.py.
9. Automated verification of the session-less credential check in the appeals endpoint.
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
| # Set via CIPHER_MODERATOR_EMAILS environment variable (comma-separated emails). | ||
| # Example: export CIPHER_MODERATOR_EMAILS="mod1@example.com,mod2@example.com" | ||
| # If not set, all registered users will be regular users (role='user') | ||
| MODERATOR_EMAILS = { |
There was a problem hiding this comment.
🔴 HIGH RISK
This file has increased in complexity (+65) due to new moderation and audit logic but lacks any automated test coverage. This represents a high regression risk for critical security paths.
|
|
||
|
|
||
| @app.post("/api/appeals") | ||
| def create_appeal(body: AppealSubmitIn, request: Request): |
There was a problem hiding this comment.
🔴 HIGH RISK
The /api/appeals endpoint performs password verification (authHash) without session cookies. This endpoint must be rate-limited to prevent brute-force attacks and potential database exhaustion.
| 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 | ||
| ); |
There was a problem hiding this comment.
🔴 HIGH RISK
The 'appeals' table definition in the SCHEMA string is missing the 'appeal_type' column, which is required by the 'create_appeal' function and migrations. Add this column to the CREATE TABLE statement to ensure fresh installations function correctly.
| salt_bytes = bytes.fromhex(body.authSalt) | ||
| auth_hash_stored = hasher.hash(body.authHash.lower()) | ||
| now = int(time.time()) | ||
| role = "moderator" if email in MODERATOR_EMAILS else "user" |
There was a problem hiding this comment.
🟡 MEDIUM RISK
The system requires a 'super_moderator' role for role management, but no mechanism exists to assign this role. Consider updating the MODERATOR_EMAILS logic to support bootstrapping super-moderators.
| elif body.action == "ban": | ||
| new_status = "banned" | ||
| suspended_until = None | ||
| else: # restore |
There was a problem hiding this comment.
🟡 MEDIUM RISK
The moderation logic defaults to restoring a user (setting status to 'active') for any action input that isn't explicitly 'suspend' or 'ban'. Actions like 'approve' or 'reject' sent to this endpoint will unintentionally reactivate users. Explicitly validate the 'restore' action.
| const reason = $("appeal-reason").value.trim(); | ||
|
|
||
| if (!email || !type || reason.length < 50) { | ||
| return $("appeal-error").textContent = "All fields required (reason: min 50 characters)", void $("appeal-error").classList.remove("hidden"); |
There was a problem hiding this comment.
🟡 MEDIUM RISK
Suggestion: Move DOM updates out of the return expression. Assignments and side-effects inside a return statement are error-prone and reduce readability.
| $("report-preview-text").textContent = decrypted.text.slice(0, 200) + (decrypted.text.length > 200 ? "…" : ""); | ||
| $("report-preview").style.display = "block"; | ||
| } | ||
| } catch (err) { |
There was a problem hiding this comment.
⚪ LOW RISK
Nitpick: The captured error object 'err' is unused. Simplify by using an optional catch binding: 'catch { ... }'.
| <div class="modal hidden" id="submit-appeal-modal"> | ||
| <div class="modal-card sm"> | ||
| <header class="modal-head"><h3>Appeal your restriction</h3></header> | ||
| <form id="submit-appeal-form" class="form"> | ||
| <p class="muted small" style="margin-bottom:12px;">Explain why you believe your restriction should be lifted.</p> | ||
| <label class="field"> | ||
| <span>Appeal reason</span> | ||
| <textarea id="appeal-reason-input" required placeholder="Provide details about your appeal..." maxlength="1000"></textarea> | ||
| </label> | ||
| </form> | ||
| <div class="modal-foot"> | ||
| <button class="btn ghost" id="appeal-submit-cancel" type="button">Cancel</button> | ||
| <button class="btn primary" id="appeal-submit-ok" type="button">Submit appeal</button> | ||
| </div> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
⚪ LOW RISK
Nitpick: This modal is defined but never referenced in app.js. Remove this dead code to simplify the DOM.
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
static/js/app.js (1)
233-246:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHandle
mod/*hashes insidesetRoute().
authandunlockboth callsetRoute(window.location.hash.slice(1)). If the initial hash ismod/usersor similar, this code treats it as a normal app route, hides every pane, andhashchangenever rescues it because the hash did not change.Suggested fix
function setRoute(r) { if (r === "moderation") { enterModMode(); return; } + if (r.startsWith("mod/")) { + const sub = r.slice(4); + if (["dashboard", "reports", "users", "appeals", "audit"].includes(sub)) { + state.modRoute = sub; + enterModMode(); + return; + } + } if (state.modMode) return; state.route = r;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@static/js/app.js` around lines 233 - 246, setRoute currently treats hashes like "mod/users" as unknown routes and returns early when state.modMode is true, so when auth/unlock call setRoute(window.location.hash.slice(1)) the mod/* path is ignored; update setRoute(r) to detect r.startsWith("mod/") (or r === "moderation") and route into enterModMode() with the subpath (e.g., pass or set state.route to the "mod/..." value) instead of returning early on state.modMode; specifically modify setRoute, keep the enterModMode() call path, remove or adjust the early return that blocks processing when state.modMode is true, and ensure location.hash is preserved/updated for mod/* so the UI shows the correct moderation pane.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/main.py`:
- Around line 1635-1655: The mod_user_status function accepts actions beyond the
intended set because ModUserActionIn can be approve/reject; update
mod_user_status to explicitly validate body.action against the allowed values
("suspend", "ban", "restore") before applying changes (use an early
HTTPException for invalid actions) or replace ModUserActionIn with a dedicated
request model/enumeration that only permits those three values; reference the
function name mod_user_status and the request field body.action when adding the
check and ensure the suspend/ban/restore logic only runs after validation.
- Around line 1763-1780: After fetching user_row from db() in the appeals flow,
apply the same expired-suspension normalization used by the login path: if
user_row["status"] == "suspended" and user_row.get("suspended_until") is present
and datetime.utcnow() is past that timestamp, treat the account as active
(update user_row["status"] = "active" and persist the change via an UPDATE on
the users table or clear suspended_until so future checks see the account as
active). Keep this normalization before the hasher.verify and the subsequent
"Only suspended or banned users can appeal" check so expired suspensions are
correctly handled.
- Around line 1757-1776: Add the same throttling used by the login endpoint to
the session-less credential check in create_appeal: before calling hasher.verify
in create_appeal, invoke the existing rate-limiter used by your auth/login
handler (e.g. the login endpoint's limiter function or middleware) with a key
that includes the lowercased email and request.client IP (or
request.remote_addr) so repeated guesses are limited, and if the limiter rejects
the attempt return HTTP 429 rather than proceeding; ensure you import/ reuse the
same limiter symbol and error handling used by the POST /api/auth/login path so
behavior and configuration remain consistent.
- Around line 1784-1797: The SELECT-then-INSERT is vulnerable to TOCTOU races;
enforce the "one pending appeal" rule at the database level and handle
conflicts: add a unique partial index on appeals(user_id) WHERE status='pending'
(so the DB rejects concurrent INSERTs), then remove the pre-check or keep it for
fast-fail and wrap the INSERT executed via conn.execute(...) in an exception
handler catching the DB integrity constraint error
(IntegrityError/OperationalError) and translate it to raise HTTPException(400,
"You already have a pending appeal"). Ensure the unique index creation runs as
part of migrations or startup and keep references to db(), conn, the INSERT
statement and the HTTPException mapping when implementing the change.
In `@static/index.html`:
- Line 163: Two different buttons share the id "appeal-cancel", causing
document.getElementById("appeal-cancel") to resolve to the wrong element; give
the modal's cancel button a unique id (e.g., "appeal-modal-cancel") and update
all corresponding selectors in the JS (search for "appeal-cancel" usage in
static/js/app.js and change to the new id where the modal's handler is
attached), ensuring the page-level back button keeps its original id or is
likewise renamed if needed so each getElementById call targets the intended
element.
In `@static/js/app.js`:
- Around line 357-364: After a restricted-login path stores credentials
(state.userEmail, state.authHash, state.loginRestriction) and calls
showSuspensionScreen, clear sensitive secrets immediately: set state.authHash =
null (and clear any password input value/placeholder and hide the show-password
control) before showing the suspension/appeal UI. Also ensure any "back"/cancel
handlers that return from the suspension view explicitly clear state.authHash
and reset the password input and show-password toggle so credentials cannot be
revealed later; update the suspension-entry code and the suspension-exit/back
handlers to perform these clears.
- Around line 1091-1092: The issue is that persistent elements (e.g.
reportsSearchInput) get new event listeners each time
loadModeration()/renderReports() runs, causing duplicated handlers; to fix, stop
rebinding on refresh by adding a one-time guard and only attach listeners once
(for example introduce a boolean flag like moderationListenersBound and in
loadModeration() or the module init check if false before calling
reportsSearchInput.addEventListener("input", renderReports) and the other
handlers referenced around lines 1183-1189, then set the flag true), or
alternatively move those addEventListener calls out of the refresh path into the
module initialization so renderReports()/loadModeration() only update UI state
and do not re-register listeners.
---
Outside diff comments:
In `@static/js/app.js`:
- Around line 233-246: setRoute currently treats hashes like "mod/users" as
unknown routes and returns early when state.modMode is true, so when auth/unlock
call setRoute(window.location.hash.slice(1)) the mod/* path is ignored; update
setRoute(r) to detect r.startsWith("mod/") (or r === "moderation") and route
into enterModMode() with the subpath (e.g., pass or set state.route to the
"mod/..." value) instead of returning early on state.modMode; specifically
modify setRoute, keep the enterModMode() call path, remove or adjust the early
return that blocks processing when state.modMode is true, and ensure
location.hash is preserved/updated for mod/* so the UI shows the correct
moderation pane.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 44829296-8439-41da-a425-5f14073c2012
📒 Files selected for processing (4)
backend/main.pystatic/index.htmlstatic/js/app.jsstatic/js/sw-register.js
| def mod_user_status(user_id: int, body: ModUserActionIn, user = Depends(require_moderator)): | ||
| if user_id == user["id"]: | ||
| raise HTTPException(400, "Cannot modify your own account") | ||
|
|
||
| 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") | ||
|
|
||
| # Determine new status and suspended_until | ||
| if body.action == "suspend": | ||
| new_status = "suspended" | ||
| suspended_until = now + (body.duration_days * 86400) if body.duration_days else now + (7 * 86400) | ||
| elif body.action == "ban": | ||
| new_status = "banned" | ||
| suspended_until = None | ||
| else: # restore | ||
| new_status = "active" | ||
| suspended_until = None | ||
|
|
There was a problem hiding this comment.
Reject unsupported actions in mod_user_status.
ModUserActionIn also allows approve and reject, and those values currently fall through the else branch on Line 1652 and restore the target account. Validate this endpoint against only suspend|ban|restore, or give it a dedicated request model.
Suggested fix
`@app.post`("/api/mod/users/{user_id}/status")
def mod_user_status(user_id: int, body: ModUserActionIn, user = Depends(require_moderator)):
+ if body.action not in ("suspend", "ban", "restore"):
+ raise HTTPException(400, "Action must be suspend, ban, or restore")
if user_id == user["id"]:
raise HTTPException(400, "Cannot modify your own account")📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def mod_user_status(user_id: int, body: ModUserActionIn, user = Depends(require_moderator)): | |
| if user_id == user["id"]: | |
| raise HTTPException(400, "Cannot modify your own account") | |
| 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") | |
| # Determine new status and suspended_until | |
| if body.action == "suspend": | |
| new_status = "suspended" | |
| suspended_until = now + (body.duration_days * 86400) if body.duration_days else now + (7 * 86400) | |
| elif body.action == "ban": | |
| new_status = "banned" | |
| suspended_until = None | |
| else: # restore | |
| new_status = "active" | |
| suspended_until = None | |
| def mod_user_status(user_id: int, body: ModUserActionIn, user = Depends(require_moderator)): | |
| if body.action not in ("suspend", "ban", "restore"): | |
| raise HTTPException(400, "Action must be suspend, ban, or restore") | |
| if user_id == user["id"]: | |
| raise HTTPException(400, "Cannot modify your own account") | |
| 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") | |
| # Determine new status and suspended_until | |
| if body.action == "suspend": | |
| new_status = "suspended" | |
| suspended_until = now + (body.duration_days * 86400) if body.duration_days else now + (7 * 86400) | |
| elif body.action == "ban": | |
| new_status = "banned" | |
| suspended_until = None | |
| else: # restore | |
| new_status = "active" | |
| suspended_until = None |
🧰 Tools
🪛 Ruff (0.15.13)
[warning] 1635-1635: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable
(B008)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/main.py` around lines 1635 - 1655, The mod_user_status function
accepts actions beyond the intended set because ModUserActionIn can be
approve/reject; update mod_user_status to explicitly validate body.action
against the allowed values ("suspend", "ban", "restore") before applying changes
(use an early HTTPException for invalid actions) or replace ModUserActionIn with
a dedicated request model/enumeration that only permits those three values;
reference the function name mod_user_status and the request field body.action
when adding the check and ensure the suspend/ban/restore logic only runs after
validation.
| def create_appeal(body: AppealSubmitIn, request: Request): | ||
| """Allow suspended/banned users to appeal their status.""" | ||
| email = body.email.lower().strip() | ||
| now = int(time.time()) | ||
|
|
||
| # Look up user and verify credentials | ||
| with db() as conn: | ||
| user_row = conn.execute( | ||
| "SELECT id, auth_hash, status FROM users WHERE email = ?", | ||
| (email,) | ||
| ).fetchone() | ||
|
|
||
| if not user_row: | ||
| raise HTTPException(401, "Invalid credentials") | ||
|
|
||
| # Verify the authHash | ||
| try: | ||
| hasher.verify(user_row["auth_hash"], body.authHash.lower()) | ||
| except (VerifyMismatchError, InvalidHash) as err: | ||
| raise HTTPException(401, "Invalid credentials") from err |
There was a problem hiding this comment.
Rate-limit the session-less credential check.
POST /api/appeals now does password verification for unauthenticated callers, but it no longer has the throttling that POST /api/auth/login applies. That reintroduces an online guessing path for suspended/banned accounts.
Suggested fix
`@app.post`("/api/appeals")
def create_appeal(body: AppealSubmitIn, request: Request):
"""Allow suspended/banned users to appeal their status."""
email = body.email.lower().strip()
+ rate_limit(f"appeal:{client_ip(request)}", 10, 60)
now = int(time.time())🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/main.py` around lines 1757 - 1776, Add the same throttling used by
the login endpoint to the session-less credential check in create_appeal: before
calling hasher.verify in create_appeal, invoke the existing rate-limiter used by
your auth/login handler (e.g. the login endpoint's limiter function or
middleware) with a key that includes the lowercased email and request.client IP
(or request.remote_addr) so repeated guesses are limited, and if the limiter
rejects the attempt return HTTP 429 rather than proceeding; ensure you import/
reuse the same limiter symbol and error handling used by the POST
/api/auth/login path so behavior and configuration remain consistent.
| with db() as conn: | ||
| user_row = conn.execute( | ||
| "SELECT id, auth_hash, status FROM users WHERE email = ?", | ||
| (email,) | ||
| ).fetchone() | ||
|
|
||
| if not user_row: | ||
| raise HTTPException(401, "Invalid credentials") | ||
|
|
||
| # Verify the authHash | ||
| try: | ||
| hasher.verify(user_row["auth_hash"], body.authHash.lower()) | ||
| except (VerifyMismatchError, InvalidHash) as err: | ||
| raise HTTPException(401, "Invalid credentials") from err | ||
|
|
||
| # User must be suspended or banned to appeal | ||
| if user_row["status"] == "active": | ||
| raise HTTPException(400, "Only suspended or banned users can appeal") |
There was a problem hiding this comment.
Apply the same expired-suspension normalization as login.
/api/auth/login auto-restores expired suspensions before deciding whether the account is restricted, but /api/appeals only checks the stored status. A user whose suspended_until has already passed can still submit an appeal until they log in again, even though they should now be treated as active.
Suggested fix
with db() as conn:
user_row = conn.execute(
- "SELECT id, auth_hash, status FROM users WHERE email = ?",
+ "SELECT id, auth_hash, status, suspended_until FROM users WHERE email = ?",
(email,)
).fetchone()
if not user_row:
raise HTTPException(401, "Invalid credentials")
+
+ if (
+ user_row["status"] == "suspended"
+ and user_row["suspended_until"]
+ and user_row["suspended_until"] <= now
+ ):
+ with db() as conn:
+ conn.execute(
+ "UPDATE users SET status = 'active', suspended_until = NULL WHERE id = ?",
+ (user_row["id"],),
+ )
+ user_row = dict(user_row)
+ user_row["status"] = "active"
# Verify the authHash🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/main.py` around lines 1763 - 1780, After fetching user_row from db()
in the appeals flow, apply the same expired-suspension normalization used by the
login path: if user_row["status"] == "suspended" and
user_row.get("suspended_until") is present and datetime.utcnow() is past that
timestamp, treat the account as active (update user_row["status"] = "active" and
persist the change via an UPDATE on the users table or clear suspended_until so
future checks see the account as active). Keep this normalization before the
hasher.verify and the subsequent "Only suspended or banned users can appeal"
check so expired suspensions are correctly handled.
| # Check if user already has a pending appeal | ||
| with db() as conn: | ||
| 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, appeal_type, reason, created_at) VALUES (?, ?, ?, ?, ?)", | ||
| (user_id, "pending", body.appeal_type, body.reason, now) | ||
| ) |
There was a problem hiding this comment.
Enforce the “one pending appeal” rule in the database.
The SELECT-then-INSERT sequence is a TOCTOU race. Two concurrent submissions can both miss existing and create duplicate pending appeals, which breaks the contract this endpoint is meant to enforce.
Suggested fix
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);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_appeals_one_pending_per_user
+ON appeals(user_id) WHERE status = 'pending';- 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, appeal_type, reason, created_at) VALUES (?, ?, ?, ?, ?)",
- (user_id, "pending", body.appeal_type, body.reason, now)
- )
+ try:
+ conn.execute(
+ "INSERT INTO appeals (user_id, status, appeal_type, reason, created_at) VALUES (?, ?, ?, ?, ?)",
+ (user_id, "pending", body.appeal_type, body.reason, now)
+ )
+ except sqlite3.IntegrityError as err:
+ raise HTTPException(400, "You already have a pending appeal") from err🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/main.py` around lines 1784 - 1797, The SELECT-then-INSERT is
vulnerable to TOCTOU races; enforce the "one pending appeal" rule at the
database level and handle conflicts: add a unique partial index on
appeals(user_id) WHERE status='pending' (so the DB rejects concurrent INSERTs),
then remove the pre-check or keep it for fast-fail and wrap the INSERT executed
via conn.execute(...) in an exception handler catching the DB integrity
constraint error (IntegrityError/OperationalError) and translate it to raise
HTTPException(400, "You already have a pending appeal"). Ensure the unique index
creation runs as part of migrations or startup and keep references to db(),
conn, the INSERT statement and the HTTPException mapping when implementing the
change.
|
|
||
| <p id="appeal-error" class="form-error hidden"></p> | ||
| <div class="modal-foot" style="margin-top:20px;"> | ||
| <button class="btn ghost" type="button" id="appeal-cancel">Back</button> |
There was a problem hiding this comment.
Make the appeal-cancel IDs unique.
These two buttons share the same id, so getElementById("appeal-cancel") resolves to the page-level back button first. The moderator appeal-review modal then binds its cancel handler to the wrong element, leaving the modal's own Cancel button inert.
Suggested fix
- <button class="btn ghost" type="button" id="appeal-cancel">Back</button>
+ <button class="btn ghost" type="button" id="appeal-page-cancel">Back</button>
...
- <button class="btn ghost" id="appeal-cancel" type="button">Cancel</button>
+ <button class="btn ghost" id="appeal-review-cancel" type="button">Cancel</button>Then update the corresponding selectors in static/js/app.js.
Also applies to: 684-684
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@static/index.html` at line 163, Two different buttons share the id
"appeal-cancel", causing document.getElementById("appeal-cancel") to resolve to
the wrong element; give the modal's cancel button a unique id (e.g.,
"appeal-modal-cancel") and update all corresponding selectors in the JS (search
for "appeal-cancel" usage in static/js/app.js and change to the new id where the
modal's handler is attached), ensuring the page-level back button keeps its
original id or is likewise renamed if needed so each getElementById call targets
the intended element.
| const loginRes = await api.post("/api/auth/login", { email, authHash }); | ||
|
|
||
| if (loginRes.restricted) { | ||
| state.userEmail = email; | ||
| state.authHash = authHash; | ||
| state.loginRestriction = { type: loginRes.restrictionType, timeRemaining: loginRes.timeRemaining }; | ||
| showSuspensionScreen(loginRes.restrictionType, loginRes.timeRemaining); | ||
| return; |
There was a problem hiding this comment.
Clear restricted-login secrets when leaving the auth flow.
After a restricted login, the entered password remains in the auth form and authHash stays in memory. The suspension/appeal “back” actions only swap views, so on a shared device the previous credentials are still sitting in the page and can be revealed via the existing show-password control.
Suggested fix
+function clearRestrictedAuthState() {
+ state.authHash = null;
+ state.userEmail = null;
+ state.loginRestriction = null;
+ $("auth-form").reset();
+}
+
if (loginRes.restricted) {
state.userEmail = email;
state.authHash = authHash;
state.loginRestriction = { type: loginRes.restrictionType, timeRemaining: loginRes.timeRemaining };
+ $("auth-password").value = "";
+ $("auth-confirm").value = "";
showSuspensionScreen(loginRes.restrictionType, loginRes.timeRemaining);
return;
}
...
-$("susp-logout-btn").addEventListener("click", () => { setView("auth"); });
+$("susp-logout-btn").addEventListener("click", () => {
+ clearRestrictedAuthState();
+ setView("auth");
+});
...
-$("appeal-back-btn").addEventListener("click", () => { setView("auth"); });
+$("appeal-back-btn").addEventListener("click", () => {
+ clearRestrictedAuthState();
+ setView("auth");
+});Also applies to: 431-467
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@static/js/app.js` around lines 357 - 364, After a restricted-login path
stores credentials (state.userEmail, state.authHash, state.loginRestriction) and
calls showSuspensionScreen, clear sensitive secrets immediately: set
state.authHash = null (and clear any password input value/placeholder and hide
the show-password control) before showing the suspension/appeal UI. Also ensure
any "back"/cancel handlers that return from the suspension view explicitly clear
state.authHash and reset the password input and show-password toggle so
credentials cannot be revealed later; update the suspension-entry code and the
suspension-exit/back handlers to perform these clears.
| renderReports(); | ||
| reportsSearchInput.addEventListener("input", renderReports); |
There was a problem hiding this comment.
Don't rebind moderation listeners on every refresh.
loadModeration() runs on mode entry and again after suspend/ban/restore/review actions. These addEventListener calls target persistent elements, so each refresh stacks another handler and later clicks can open multiple dialogs or submit duplicate status changes.
Suggested fix
- reportsSearchInput.addEventListener("input", renderReports);
+ reportsSearchInput.oninput = renderReports;
...
- searchInput.addEventListener("input", renderUsersList);
- filterSelect.addEventListener("change", renderUsersList);
+ searchInput.oninput = renderUsersList;
+ filterSelect.onchange = renderUsersList;
...
- usersList.addEventListener("click", async (e) => {
+ usersList.onclick = async (e) => {
const btn = e.target.closest("[data-mod-action]");
if (!btn) return;
...
- });
+ };Also applies to: 1183-1189
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@static/js/app.js` around lines 1091 - 1092, The issue is that persistent
elements (e.g. reportsSearchInput) get new event listeners each time
loadModeration()/renderReports() runs, causing duplicated handlers; to fix, stop
rebinding on refresh by adding a one-time guard and only attach listeners once
(for example introduce a boolean flag like moderationListenersBound and in
loadModeration() or the module init check if false before calling
reportsSearchInput.addEventListener("input", renderReports) and the other
handlers referenced around lines 1183-1189, then set the flag true), or
alternatively move those addEventListener calls out of the refresh path into the
module initialization so renderReports()/loadModeration() only update UI state
and do not re-register listeners.
Summary
Fixed critical authentication bug preventing suspended/banned users from submitting appeals. Suspended users couldn't access the appeal endpoint because it required a valid authenticated session, which they never received due to intentional login blocking.
Changes
Backend (
main.py)AppealSubmitInmodel to includeauthHashfield for credential verificationPOST /api/appealsendpoint to removeauth_deprequirement and instead verify credentials directly using email + authHash (matching login flow)hasher.verify(), and session-less submissionFrontend (
app.js)authHashin state when login returns restriction response (user has already verified credentials)authHashin appeal form submission payloadTechnical Details
Suspended users follow this flow:
statefor later useTesting
https://claude.ai/code/session_01Tq7iYMZVHmyeByUxiUj2iH
Generated by Claude Code
Summary by CodeRabbit
New Features
Chores