Files
devplacepy/devplacepy/routers/reactions.py
T
retoor 19cc85f409 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
2026-06-21 16:46:27 +00:00

99 lines
3.1 KiB
Python

# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
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, redirect_back
from devplacepy.models import ReactionForm
from devplacepy.services.audit import record as audit
from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
REACTABLE: set[str] = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}")
async def react(
request: Request,
target_type: str,
target_uid: str,
data: Annotated[ReactionForm, Depends(json_or_form(ReactionForm))],
):
user = require_user(request)
if target_type not in REACTABLE:
return JSONResponse({"error": "Invalid target"}, status_code=400)
emoji = data.emoji
reactions = get_table("reactions")
existing = reactions.find_one(
user_uid=user["uid"],
target_uid=target_uid,
target_type=target_type,
emoji=emoji,
)
if existing and existing.get("deleted_at"):
reactions.update(
{"id": existing["id"], "deleted_at": None, "deleted_by": None}, ["id"]
)
added = True
elif existing:
reactions.update(
{"id": existing["id"], "deleted_at": _now_iso(), "deleted_by": user["uid"]},
["id"],
)
added = False
else:
reactions.insert(
{
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target_uid,
"target_type": target_type,
"emoji": emoji,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
}
)
added = True
reaction_event = "reaction.add" if added else "reaction.remove"
if added:
track_action(user["uid"], "reaction")
audit.record(
request,
reaction_event,
user=user,
target_type=target_type,
target_uid=target_uid,
metadata={"emoji": emoji},
summary=f"{user['username']} {'added' if added else 'removed'} {emoji} on {target_type} {target_uid}",
links=[audit.target(target_type, target_uid)],
)
counts = {
row["emoji"]: row["c"]
for row in db.query(
"SELECT emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid=:tu AND deleted_at IS NULL GROUP BY emoji",
tt=target_type,
tu=target_uid,
)
}
mine = [
row["emoji"]
for row in reactions.find(
user_uid=user["uid"],
target_uid=target_uid,
target_type=target_type,
deleted_at=None,
)
]
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse({"counts": counts, "mine": mine})
return RedirectResponse(url=redirect_back(request), status_code=302)