Puestas de seguridad y limpieza de codigo
This commit is contained in:
@@ -2,12 +2,13 @@ import shutil
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.config import settings
|
||||
from core.security import get_current_user, has_role
|
||||
from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||
from .services import HelpCenterService
|
||||
from .tasks import sync_single_article_task
|
||||
@@ -80,7 +81,10 @@ def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core
|
||||
return result
|
||||
|
||||
@router.post("/upload-image/")
|
||||
def upload_help_image(file: UploadFile = File(...)):
|
||||
def upload_help_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Sube una imagen para usar en los artículos."""
|
||||
try:
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
@@ -98,7 +102,10 @@ def upload_help_image(file: UploadFile = File(...)):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/upload-asset/")
|
||||
def upload_help_asset(file: UploadFile = File(...)):
|
||||
def upload_help_asset(
|
||||
file: UploadFile = File(...),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca."""
|
||||
try:
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
@@ -132,17 +139,24 @@ def upload_help_asset(file: UploadFile = File(...)):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/articles/", response_model=List[HelpArticleInDB])
|
||||
def list_articles(db: Session = Depends(get_core_db)):
|
||||
def list_articles(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Lista todos los artículos de ayuda."""
|
||||
return HelpCenterService.get_all(db)
|
||||
|
||||
@router.get("/modifications/", response_model=List[HelpArticleInDB])
|
||||
@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)])
|
||||
def get_modifications(since: datetime, db: Session = Depends(get_core_db)):
|
||||
"""Obtiene artículos modificados desde la fecha indicada (Polling)."""
|
||||
"""Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token."""
|
||||
return HelpCenterService.get_modifications(db, since)
|
||||
|
||||
@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||
def get_article(article_uuid: UUID, db: Session = Depends(get_core_db)):
|
||||
def get_article(
|
||||
article_uuid: UUID,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Obtiene un artículo por UUID."""
|
||||
article = HelpCenterService.get_by_uuid(db, article_uuid)
|
||||
if not article:
|
||||
@@ -150,12 +164,20 @@ def get_article(article_uuid: UUID, db: Session = Depends(get_core_db)):
|
||||
return article
|
||||
|
||||
@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED)
|
||||
def create_article(article: HelpArticleCreate, db: Session = Depends(get_core_db)):
|
||||
def create_article(
|
||||
article: HelpArticleCreate,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Crea un nuevo artículo."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"DEBUG: Creating new article: {article.title}")
|
||||
logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}")
|
||||
|
||||
# Fill last_editor with admin username
|
||||
if current_user.get('preferred_username'):
|
||||
article.last_editor = current_user.get('preferred_username')
|
||||
|
||||
new_article = HelpCenterService.create(db, article)
|
||||
logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}")
|
||||
|
||||
@@ -164,8 +186,16 @@ def create_article(article: HelpArticleCreate, db: Session = Depends(get_core_db
|
||||
return new_article
|
||||
|
||||
@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||
def update_article(article_uuid: UUID, article_data: HelpArticleUpdate, db: Session = Depends(get_core_db)):
|
||||
def update_article(
|
||||
article_uuid: UUID,
|
||||
article_data: HelpArticleUpdate,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Actualiza un artículo."""
|
||||
if current_user.get('preferred_username'):
|
||||
article_data.last_editor = current_user.get('preferred_username')
|
||||
|
||||
article = HelpCenterService.update(db, article_uuid, article_data)
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
@@ -175,7 +205,11 @@ def update_article(article_uuid: UUID, article_data: HelpArticleUpdate, db: Sess
|
||||
return article
|
||||
|
||||
@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_article(article_uuid: UUID, db: Session = Depends(get_core_db)):
|
||||
def delete_article(
|
||||
article_uuid: UUID,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Elimina un artículo."""
|
||||
if not HelpCenterService.delete(db, article_uuid):
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
|
||||
@@ -50,7 +50,6 @@ class HelpSyncRequest(BaseModel):
|
||||
client_file_url: Optional[str] = None
|
||||
client_file_size: Optional[int] = None
|
||||
client_mime_type: Optional[str] = None
|
||||
origin_client_uuid: Optional[UUID] = None
|
||||
|
||||
class HelpSyncResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
@@ -149,11 +149,6 @@ class HelpCenterService:
|
||||
"""
|
||||
Lógica de sincronización "Smart Sync" (Last Write Wins).
|
||||
"""
|
||||
# Loop Prevention: If the update originated from this client, ignore it.
|
||||
from core.config import settings
|
||||
if settings.CLIENT_UUID and sync_data.origin_client_uuid and str(sync_data.origin_client_uuid) == settings.CLIENT_UUID:
|
||||
return HelpSyncResponse(status="OK", message="Skipped: I am the origin.")
|
||||
|
||||
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first()
|
||||
|
||||
client_updated_at = sync_data.client_updated_at
|
||||
|
||||
@@ -38,10 +38,9 @@ def sync_single_article_task(article_uuid_str: str):
|
||||
|
||||
|
||||
@shared_task(name="broadcast_help_update")
|
||||
def broadcast_help_update(article_uuid_str: str, origin_client_uuid_str: str = None):
|
||||
def broadcast_help_update(article_uuid_str: str):
|
||||
"""
|
||||
Difunde una actualización de artículo a todos los spokes configurados,
|
||||
excepto al que originó el cambio (si existe).
|
||||
Difunde una actualización de artículo a todos los spokes configurados.
|
||||
"""
|
||||
if not settings.SPOKE_URLS:
|
||||
logger.info("No SPOKE_URLS configured. Skipping broadcast.")
|
||||
@@ -65,18 +64,12 @@ def broadcast_help_update(article_uuid_str: str, origin_client_uuid_str: str = N
|
||||
client_slug=article.slug,
|
||||
last_editor=article.last_editor,
|
||||
client_category=article.category,
|
||||
client_order=article.order,
|
||||
origin_client_uuid=UUID(origin_client_uuid_str) if origin_client_uuid_str else None
|
||||
client_order=article.order
|
||||
).model_dump(mode='json')
|
||||
|
||||
with httpx.Client() as client:
|
||||
for spoke_url in spokes:
|
||||
# Loop Prevention: Skip if the spoke is the origin
|
||||
# NOTE: This assumes SPOKE_URLS contains the unique Sync URL of the client.
|
||||
# A more robust check would be comparing CLIENT_UUID if we knew the URL->UUID mapping ahead of time.
|
||||
# For this implementation, we rely on the `origin_client_uuid` being passed in the payload
|
||||
# and the receiving client checking it against its own settings.CLIENT_UUID.
|
||||
|
||||
try:
|
||||
logger.info(f"Broadcasting update to {spoke_url}")
|
||||
response = client.post(
|
||||
@@ -120,8 +113,7 @@ def sync_single_article(article_uuid):
|
||||
client_slug=article.slug,
|
||||
last_editor=article.last_editor,
|
||||
client_category=article.category,
|
||||
client_order=article.order,
|
||||
origin_client_uuid=UUID(settings.CLIENT_UUID) if settings.CLIENT_UUID else None
|
||||
client_order=article.order
|
||||
)
|
||||
|
||||
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||
|
||||
@@ -44,7 +44,6 @@ class Settings(BaseSettings):
|
||||
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
|
||||
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
|
||||
SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only)
|
||||
CLIENT_UUID: str = "" # Unique identifier for this Client instance (Spoke only)
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
@@ -106,6 +106,10 @@ def has_role(required_role: str):
|
||||
user_roles = current_user.get("realm_access", {}).get("roles", [])
|
||||
|
||||
if required_role not in user_roles:
|
||||
logger.warning(f"Role denied. Required: {required_role}. User actually has: {user_roles}")
|
||||
# Also check client roles as a debug fallback
|
||||
client_roles = current_user.get("resource_access", {})
|
||||
logger.warning(f"User client roles: {client_roles}")
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"User does not have required role: {required_role}",
|
||||
|
||||
Reference in New Issue
Block a user