Files
devplacepy/devplacepy/routers/pubsub.py
T
retoor 741d7aade6
DevPlace CI / test (push) Failing after 2m7s
docs: add block/mute user relations, emoji-sync CLI, and uid indexes
- Add `/block`, `/mute` endpoints with block/unblock and mute/unmute functionality in `routers/relations.py`, hiding blocked users' content everywhere except their own profile while muting only suppresses notifications
- Introduce `devplace emoji-sync` CLI command to regenerate `static/js/emoji-shortcodes.js` from the emoji library, documented in `CLAUDE.md` and wired in `cli.py`
- Create `get_blocked_uids()` database helper and apply it in `content.py` `load_detail()` to filter blocked users' posts from detail views
- Implement `_uid_index()` and `_drop_index()` helpers in `database.py` for unique uid indexes across tables, with `user_relations` added to `SOFT_DELETE_TABLES`
- Document new routes in `AGENTS.md` and `README.md`, including emoji shortcodes rendering behavior distinct from the emoji picker
2026-06-19 08:06:09 +00:00

126 lines
4.6 KiB
Python

# retoor <retoor@molodetz.nl>
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from devplacepy.services.manager import service_manager
from devplacepy.services.pubsub import policy
from devplacepy.services.pubsub.hub import pubsub
from devplacepy.routers.dbapi._shared import error, read_body
logger = logging.getLogger(__name__)
router = APIRouter()
def _require_privileged(request):
actor = policy.resolve_actor(request)
if not actor.privileged:
return None
return actor
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _message(topic: str, data) -> dict:
return {"type": "message", "topic": topic, "data": data, "ts": _now()}
@router.websocket("/ws")
async def pubsub_ws(websocket: WebSocket):
await websocket.accept()
svc = service_manager.get_service("pubsub")
if svc is None or not svc.is_enabled():
await websocket.close(code=1013)
return
if not service_manager.owns_lock():
await websocket.close(code=4013)
return
actor = policy.resolve_actor(websocket)
if actor.kind == "guest" and not policy.guests_enabled():
await websocket.close(code=1008)
return
await websocket.send_json({"type": "ready", "actor": actor.kind})
try:
while True:
data = await websocket.receive_json()
kind = data.get("type")
topic = str(data.get("topic", "")).strip()
if kind in ("subscribe", "unsubscribe", "publish") and not policy.valid_topic(
topic
):
await websocket.send_json({"type": "error", "message": "Invalid topic name."})
continue
if kind == "subscribe":
if policy.can_subscribe(actor, topic):
pubsub.subscribe(topic, websocket)
await websocket.send_json({"type": "subscribed", "topic": topic})
else:
await websocket.send_json(
{"type": "error", "message": f"Not allowed to subscribe to {topic}."}
)
elif kind == "unsubscribe":
pubsub.unsubscribe(topic, websocket)
await websocket.send_json({"type": "unsubscribed", "topic": topic})
elif kind == "publish":
if not policy.can_publish(actor, topic):
await websocket.send_json(
{"type": "error", "message": f"Not allowed to publish to {topic}."}
)
continue
payload = data.get("data")
if len(json.dumps(payload, default=str)) > policy.MAX_PAYLOAD_BYTES:
await websocket.send_json({"type": "error", "message": "Payload too large."})
continue
delivered = await pubsub.publish(topic, _message(topic, payload))
await websocket.send_json(
{"type": "ack", "topic": topic, "delivered": delivered}
)
else:
await websocket.send_json({"type": "error", "message": "Unknown frame type."})
except WebSocketDisconnect:
pass
except Exception:
logger.exception("pubsub websocket loop failed")
finally:
pubsub.drop_socket(websocket)
@router.post("/publish")
async def pubsub_http_publish(request: Request):
caller = _require_privileged(request)
if caller is None:
return error(403, "Admin or internal access required.")
if not service_manager.owns_lock():
return error(409, "Pub/sub is served by the lock-owner worker. Retry.")
body = await read_body(request)
topic = str(body.get("topic", "")).strip()
if not policy.valid_topic(topic):
return error(400, "Invalid topic name.")
payload = body.get("data")
delivered = await pubsub.publish(topic, _message(topic, payload))
from devplacepy.services.audit import record as audit
audit.record(
request,
"pubsub.publish",
target_type="topic",
target_uid=topic,
summary=f"{caller.username or caller.kind} published to {topic}",
metadata={"topic": topic, "delivered": delivered, "caller": caller.kind},
)
return JSONResponse({"topic": topic, "delivered": delivered})
@router.get("/topics")
async def pubsub_list_topics(request: Request):
if _require_privileged(request) is None:
return error(403, "Admin or internal access required.")
return JSONResponse({"topics": pubsub.topics()})