Puestas de seguridad y limpieza de codigo
This commit is contained in:
@@ -62,7 +62,6 @@ VALKEY_URL=redis://valkey:6379/0
|
||||
CENTRAL_SERVER_URL=
|
||||
|
||||
# UUID único de este cliente (Opcional, se genera uno si está vacío)
|
||||
CLIENT_UUID=
|
||||
|
||||
# Token de seguridad compartido (Debe ser IDÉNTICO en Hub y Clientes)
|
||||
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -180,7 +180,6 @@ services:
|
||||
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
|
||||
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
|
||||
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
|
||||
- CLIENT_UUID=${CLIENT_UUID:-""}
|
||||
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||
ports:
|
||||
- "3467:8000"
|
||||
@@ -225,7 +224,6 @@ services:
|
||||
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
|
||||
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
|
||||
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
|
||||
- CLIENT_UUID=${CLIENT_UUID:-""}
|
||||
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||
- CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76}
|
||||
- CORE_DB_PORT=${CORE_DB_PORT:-5432}
|
||||
|
||||
@@ -184,7 +184,6 @@ services:
|
||||
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
|
||||
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
|
||||
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
|
||||
- CLIENT_UUID=${CLIENT_UUID:-""}
|
||||
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -285,7 +284,6 @@ services:
|
||||
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
|
||||
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
|
||||
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
|
||||
- CLIENT_UUID=${CLIENT_UUID:-""}
|
||||
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||
- CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76}
|
||||
- CORE_DB_PORT=${CORE_DB_PORT:-5432}
|
||||
@@ -310,7 +308,6 @@ services:
|
||||
- VALKEY_URL=${VALKEY_URL:-redis://valkey:6379/0}
|
||||
- CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""}
|
||||
- SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production}
|
||||
- CLIENT_UUID=${CLIENT_UUID:-""}
|
||||
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||
- CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76}
|
||||
- CORE_DB_PORT=${CORE_DB_PORT:-5432}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { authStore } from '$lib/auth';
|
||||
import { getToken, authStore } from '$lib/auth';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const api_url = import.meta.env.VITE_API_URL;
|
||||
const BASE_URL = `${api_url.endsWith('/') ? api_url : api_url + '/'}v1/core/help-center`;
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
// 1. First try getToken() which checks Keycloak and localStorage
|
||||
let token = getToken();
|
||||
|
||||
// 2. If somehow empty, explicitly check authStore value
|
||||
if (!token) {
|
||||
const auth = get(authStore);
|
||||
token = auth.token;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
function getHeaders() {
|
||||
const auth = get(authStore);
|
||||
const token = getAuthToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(auth.token ? { 'Authorization': `Bearer ${auth.token}` } : {})
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,13 +42,13 @@ export interface HelpArticle {
|
||||
|
||||
export const helpApi = {
|
||||
async listArticles(): Promise<HelpArticle[]> {
|
||||
const response = await fetch(`${BASE_URL}/articles/`);
|
||||
const response = await fetch(`${BASE_URL}/articles/`, { headers: getHeaders() });
|
||||
if (!response.ok) throw new Error('Failed to fetch articles');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getArticle(uuid: string): Promise<HelpArticle> {
|
||||
const response = await fetch(`${BASE_URL}/articles/${uuid}/`);
|
||||
const response = await fetch(`${BASE_URL}/articles/${uuid}/`, { headers: getHeaders() });
|
||||
if (!response.ok) throw new Error('Failed to fetch article');
|
||||
return response.json();
|
||||
},
|
||||
@@ -46,7 +59,8 @@ export const helpApi = {
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update article');
|
||||
if (response.status === 403) throw new Error('No tienes permisos para editar artículos (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al guardar cambios');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
@@ -56,7 +70,8 @@ export const helpApi = {
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create article');
|
||||
if (response.status === 403) throw new Error('No tienes permisos para crear artículos (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al crear el artículo');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
@@ -65,7 +80,8 @@ export const helpApi = {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete article');
|
||||
if (response.status === 403) throw new Error('No tienes permisos para eliminar (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al eliminar');
|
||||
},
|
||||
|
||||
async triggerSync(): Promise<void> {
|
||||
@@ -76,15 +92,17 @@ export const helpApi = {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${BASE_URL}/upload-image/`, {
|
||||
method: 'POST',
|
||||
// No Content-Type header for FormData, browser sets it with boundary
|
||||
headers: {
|
||||
...(get(authStore).token ? { 'Authorization': `Bearer ${get(authStore).token}` } : {})
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to upload image');
|
||||
if (response.status === 403) throw new Error('No tienes permisos para subir imágenes (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al subir imagen');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
@@ -92,14 +110,16 @@ export const helpApi = {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${BASE_URL}/upload-asset/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(get(authStore).token ? { 'Authorization': `Bearer ${get(authStore).token}` } : {})
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to upload asset');
|
||||
if (response.status === 403) throw new Error('No tienes permisos para subir archivos (Requiere rol Admin)');
|
||||
if (!response.ok) throw new Error('Error al subir archivo');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
8
start.sh
8
start.sh
@@ -95,12 +95,13 @@ if ! grep -q "VITE_HUB_MODE=" .env || [ "${RECONFIGURE:-false}" == "true" ]; the
|
||||
read -p "Seleccione una opción [1-2, Enter para omitir]: " SERVER_ROLE
|
||||
|
||||
if [ "$SERVER_ROLE" == "1" ] || [ "$SERVER_ROLE" == "2" ]; then
|
||||
SYNC_TOKEN=$(openssl rand -hex 16 2>/dev/null || echo "dev-sync-token-$(date +%s)")
|
||||
GENERATED_TOKEN=$(openssl rand -hex 16 2>/dev/null || echo "dev-sync-token-$(date +%s)")
|
||||
read -p "Ingrese el Token de Sincronización Secreto [Presione Enter para generar uno aleatorio: $GENERATED_TOKEN]: " SYNC_TOKEN
|
||||
SYNC_TOKEN=${SYNC_TOKEN:-$GENERATED_TOKEN}
|
||||
|
||||
if [ "$SERVER_ROLE" == "1" ]; then
|
||||
echo -e "${GREEN}Configurando como HUB...${NC}"
|
||||
CENTRAL_URL=""
|
||||
CLIENT_ID=""
|
||||
SPOKE_URLS=""
|
||||
HUB_MODE="true"
|
||||
else
|
||||
@@ -108,15 +109,12 @@ if ! grep -q "VITE_HUB_MODE=" .env || [ "${RECONFIGURE:-false}" == "true" ]; the
|
||||
HUB_MODE="false"
|
||||
read -p "Ingrese la URL del Hub [http://100.78.6.108:8001/api/v1/core/help-center/sync/]: " CENTRAL_URL
|
||||
CENTRAL_URL=${CENTRAL_URL:-http://100.78.6.108:8001/api/v1/core/help-center/sync/}
|
||||
read -p "Ingrese el UUID de este cliente (presione Enter para generar uno): " CLIENT_ID
|
||||
CLIENT_ID=${CLIENT_ID:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "client-$(date +%s)")}
|
||||
SPOKE_URLS=""
|
||||
fi
|
||||
|
||||
# Aplicar configuraciones
|
||||
sed -i "s|^CENTRAL_SERVER_URL=.*|CENTRAL_SERVER_URL=$CENTRAL_URL|" .env 2>/dev/null || echo "CENTRAL_SERVER_URL=$CENTRAL_URL" >> .env
|
||||
sed -i "s|^SPOKE_URLS=.*|SPOKE_URLS=$SPOKE_URLS|" .env 2>/dev/null || echo "SPOKE_URLS=$SPOKE_URLS" >> .env
|
||||
sed -i "s|^CLIENT_UUID=.*|CLIENT_UUID=$CLIENT_ID|" .env 2>/dev/null || echo "CLIENT_UUID=$CLIENT_ID" >> .env
|
||||
if ! grep -q "SYNC_SECRET_TOKEN=" .env; then
|
||||
echo "SYNC_SECRET_TOKEN=$SYNC_TOKEN" >> .env
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user