fix: correct audit event names, attachment deletion, redirect safety, and add account deactivation check

- Fix audit event names in CLI prune/clear commands from `cli.seo.*` to `cli.seo_meta.*`
- Refactor `_delete_attachment_file` to accept full attachment dict instead of storage_path string, using directory and stored_name fields with ATTACHMENTS_DIR
- Add `safe_next` validation for referer header in validation error redirect and media redirect
- Add `is_active` check in login router to reject deactivated accounts with "Account is deactivated" error
- Replace raw `request.headers.get("Referer")` with `redirect_back()` utility in bookmarks, polls, reactions, and votes routers
- Move `mark_conversation_read` call from `get_conversation_messages` to `messages_page` to avoid side effects during message retrieval
- Fix poll audit link to use `option.get("label")` instead of `option.get("text")`
- Add `VOTABLE` set validation in votes router to reject invalid target types with 400 response
- Strip control characters (0x00-0x20) from URLs in `_safe_url` instead of simple strip
- Add `__getattr__` fallback in services `__init__.py` for dynamic attribute access
This commit is contained in:
2026-06-21 16:46:27 +00:00
parent a3dd747d07
commit 19cc85f409
31 changed files with 202 additions and 344 deletions
+2
View File
@@ -59,6 +59,8 @@ async def login(request: Request, data: Annotated[LoginForm, Depends(json_or_for
if not user or not await verify_password_async(password, user["password_hash"]):
errors.append("Invalid email or password")
elif not user.get("is_active", True):
errors.append("Account is deactivated")
if errors:
audit.record(
+2 -4
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
from devplacepy.database import get_table, db, paginate, resolve_object_url, _now_iso
from devplacepy.utils import generate_uid, require_user, time_ago
from devplacepy.utils import generate_uid, require_user, time_ago, redirect_back
from devplacepy.seo import base_seo_context
from devplacepy.responses import respond
from devplacepy.schemas import SavedOut
@@ -137,6 +137,4 @@ async def toggle_bookmark(request: Request, target_type: str, target_uid: str):
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse({"saved": saved})
return RedirectResponse(
url=request.headers.get("Referer", "/feed"), status_code=302
)
return RedirectResponse(url=redirect_back(request), status_code=302)
+2 -2
View File
@@ -4,7 +4,7 @@ import logging
from fastapi import APIRouter, Request
from devplacepy.database import get_table
from devplacepy.attachments import soft_delete_attachment, restore_attachment
from devplacepy.utils import require_user, require_admin, is_admin, not_found
from devplacepy.utils import require_user, require_admin, is_admin, not_found, safe_next
from devplacepy.responses import action_result, wants_json, json_error
from devplacepy.services.audit import record as audit
@@ -14,7 +14,7 @@ router = APIRouter()
def _media_redirect(request: Request, attachment: dict) -> str:
referer = request.headers.get("referer", "")
if referer:
if referer and safe_next(referer, "") == referer:
return referer
uploader = get_table("users").find_one(uid=attachment.get("user_uid"))
if uploader:
+1 -2
View File
@@ -111,8 +111,6 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m)
msgs.sort(key=lambda m: m["created_at"])
mark_conversation_read(user_uid, other_uid)
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
users_map = get_users_by_uids(user_ids)
other_user = users_map.get(other_uid)
@@ -150,6 +148,7 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
other_last_seen = None
if with_uid:
messages, other_user = get_conversation_messages(user["uid"], with_uid)
mark_conversation_read(user["uid"], with_uid)
current_conversation = with_uid
other_online = message_hub.is_online(with_uid)
other_last_seen = message_hub.last_seen(with_uid)
+3 -5
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from fastapi import Depends, APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, get_poll_for_post, _now_iso
from devplacepy.utils import generate_uid, require_user, track_action
from devplacepy.utils import generate_uid, require_user, track_action, redirect_back
from devplacepy.models import PollVoteForm
from devplacepy.services.audit import record as audit
from devplacepy.dependencies import json_or_form
@@ -76,7 +76,7 @@ async def vote_poll(
new_value=None if poll_event == "poll.vote.clear" else data.option_uid,
metadata={"question": poll.get("question")},
summary=f"{user['username']} {poll_event.split('.')[-1]} vote in poll {poll.get('question')}",
links=[audit.poll(poll_uid, poll.get("question")), audit.option(data.option_uid, option.get("text"))],
links=[audit.poll(poll_uid, poll.get("question")), audit.option(data.option_uid, option.get("label"))],
)
if poll_event == "poll.vote.cast":
track_action(user["uid"], "poll")
@@ -84,6 +84,4 @@ async def vote_poll(
result = get_poll_for_post(poll["post_uid"], user)
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse(result)
return RedirectResponse(
url=request.headers.get("Referer", "/feed"), status_code=302
)
return RedirectResponse(url=redirect_back(request), status_code=302)
+2 -4
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from fastapi import Depends, APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, _now_iso
from devplacepy.utils import generate_uid, require_user, track_action
from devplacepy.utils import generate_uid, require_user, track_action, redirect_back
from devplacepy.models import ReactionForm
from devplacepy.services.audit import record as audit
from devplacepy.dependencies import json_or_form
@@ -95,6 +95,4 @@ async def react(
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse({"counts": counts, "mine": mine})
return RedirectResponse(
url=request.headers.get("Referer", "/feed"), status_code=302
)
return RedirectResponse(url=redirect_back(request), status_code=302)
+7 -3
View File
@@ -4,7 +4,7 @@ import logging
from typing import Annotated
from fastapi import Depends, APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.utils import require_user
from devplacepy.utils import require_user, redirect_back
from devplacepy.content import apply_vote
from devplacepy.models import VoteForm
from devplacepy.dependencies import json_or_form
@@ -12,6 +12,9 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
VOTABLE = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}")
async def vote(
request: Request,
@@ -20,6 +23,8 @@ async def vote(
data: Annotated[VoteForm, Depends(json_or_form(VoteForm))],
):
user = require_user(request)
if target_type not in VOTABLE:
return JSONResponse({"error": "Invalid target"}, status_code=400)
result = apply_vote(request, user, target_type, target_uid, data.value)
if request.headers.get("x-requested-with") == "fetch":
@@ -32,5 +37,4 @@ async def vote(
)
return JSONResponse(result)
referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302)
return RedirectResponse(url=redirect_back(request), status_code=302)