fix: community endpoint quality issues — input validation, slot key guard, slug collision, ValueError handling
This commit is contained in:
parent
9ae886aabf
commit
69e1b70072
2 changed files with 62 additions and 19 deletions
|
|
@ -119,12 +119,39 @@ async def local_feed():
|
||||||
return [_post_to_dict(p) for p in posts]
|
return [_post_to_dict(p) for p in posts]
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_POST_TYPES = {"plan", "recipe_success", "recipe_blooper"}
|
||||||
|
_MAX_TITLE_LEN = 200
|
||||||
|
_MAX_TEXT_LEN = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_publish_body(body: dict) -> None:
|
||||||
|
"""Raise HTTPException(422) for any invalid fields in a publish request."""
|
||||||
|
post_type = body.get("post_type", "plan")
|
||||||
|
if post_type not in _VALID_POST_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"post_type must be one of: {', '.join(sorted(_VALID_POST_TYPES))}",
|
||||||
|
)
|
||||||
|
title = body.get("title") or ""
|
||||||
|
if len(title) > _MAX_TITLE_LEN:
|
||||||
|
raise HTTPException(status_code=422, detail=f"title exceeds {_MAX_TITLE_LEN} character limit.")
|
||||||
|
for field in ("description", "outcome_notes", "recipe_name"):
|
||||||
|
value = body.get(field)
|
||||||
|
if value and len(str(value)) > _MAX_TEXT_LEN:
|
||||||
|
raise HTTPException(status_code=422, detail=f"{field} exceeds {_MAX_TEXT_LEN} character limit.")
|
||||||
|
photo_url = body.get("photo_url")
|
||||||
|
if photo_url and not str(photo_url).startswith("https://"):
|
||||||
|
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts", status_code=201)
|
@router.post("/posts", status_code=201)
|
||||||
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
if not can_use("community_publish", session.tier, session.has_byok):
|
if not can_use("community_publish", session.tier, session.has_byok):
|
||||||
raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.")
|
raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.")
|
||||||
|
|
||||||
|
_validate_publish_body(body)
|
||||||
|
|
||||||
store = _get_community_store()
|
store = _get_community_store()
|
||||||
if store is None:
|
if store is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -144,7 +171,10 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
s.close()
|
||||||
pseudonym = await asyncio.to_thread(_get_pseudonym)
|
try:
|
||||||
|
pseudonym = await asyncio.to_thread(_get_pseudonym)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")]
|
recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")]
|
||||||
from app.services.community.element_snapshot import compute_snapshot
|
from app.services.community.element_snapshot import compute_snapshot
|
||||||
|
|
@ -156,17 +186,18 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
s.close()
|
s.close()
|
||||||
snapshot = await asyncio.to_thread(_snapshot)
|
snapshot = await asyncio.to_thread(_snapshot)
|
||||||
|
|
||||||
|
post_type = body.get("post_type", "plan")
|
||||||
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
|
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
|
||||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
slug = f"kiwi-{_post_type_prefix(body.get('post_type', 'plan'))}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
|
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
|
||||||
|
|
||||||
from circuitforge_core.community.models import CommunityPost
|
from circuitforge_core.community.models import CommunityPost
|
||||||
post = CommunityPost(
|
post = CommunityPost(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
pseudonym=pseudonym,
|
pseudonym=pseudonym,
|
||||||
post_type=body.get("post_type", "plan"),
|
post_type=post_type,
|
||||||
published=datetime.now(timezone.utc),
|
published=datetime.now(timezone.utc),
|
||||||
title=body.get("title", "Untitled"),
|
title=(body.get("title") or "Untitled")[:_MAX_TITLE_LEN],
|
||||||
description=body.get("description"),
|
description=body.get("description"),
|
||||||
photo_url=body.get("photo_url"),
|
photo_url=body.get("photo_url"),
|
||||||
slots=body.get("slots", []),
|
slots=body.get("slots", []),
|
||||||
|
|
@ -189,7 +220,16 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
moisture_pct=snapshot.moisture_pct,
|
moisture_pct=snapshot.moisture_pct,
|
||||||
)
|
)
|
||||||
|
|
||||||
inserted = await asyncio.to_thread(store.insert_post, post)
|
try:
|
||||||
|
inserted = await asyncio.to_thread(store.insert_post, post)
|
||||||
|
except Exception as exc:
|
||||||
|
exc_str = str(exc).lower()
|
||||||
|
if "unique" in exc_str or "duplicate" in exc_str:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="A post with this title already exists today. Try a different title.",
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
return _post_to_dict(inserted)
|
return _post_to_dict(inserted)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -226,6 +266,10 @@ async def fork_post(slug: str, session: CloudUser = Depends(get_session)):
|
||||||
if post.post_type != "plan":
|
if post.post_type != "plan":
|
||||||
raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.")
|
raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.")
|
||||||
|
|
||||||
|
required_slot_keys = {"day", "meal_type", "recipe_id"}
|
||||||
|
if any(not required_slot_keys.issubset(slot) for slot in post.slots):
|
||||||
|
raise HTTPException(status_code=400, detail="Post contains malformed slots and cannot be forked.")
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
week_start = date.today().strftime("%Y-%m-%d")
|
week_start = date.today().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate
|
||||||
|
from app.api.endpoints.community import router as community_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||||
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
|
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
|
||||||
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
|
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
|
||||||
api_router.include_router(export.router, tags=["export"])
|
api_router.include_router(export.router, tags=["export"])
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
||||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||||
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
|
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
|
||||||
|
|
||||||
from app.api.endpoints.community import router as community_router
|
|
||||||
api_router.include_router(community_router)
|
api_router.include_router(community_router)
|
||||||
Loading…
Reference in a new issue