From af1b8e995684f937680977df48725d362d1e9424 Mon Sep 17 00:00:00 2001 From: Mireya Cueto Garrido Date: Fri, 8 May 2026 11:19:52 +0200 Subject: [PATCH 1/5] feat: enhance backend security and configuration - Updated Dockerfile to improve security with a non-root user and added health checks. - Modified docker-compose.yml to set containers as read-only, restrict ports to localhost, and implement health checks. - Enhanced .env.example with additional environment variables for security and configuration. - Improved FastAPI application with middleware for security headers, CORS, and body size limits. - Refactored authentication flow in auth.py to include state validation and improved error handling. - Added rate limiting to various endpoints to prevent abuse. - Updated researcher and publication handling to ensure better validation and error management. --- .gitignore | 7 + backend/.dockerignore | 20 ++ backend/.env.example | 78 +++++++- backend/Dockerfile | 32 ++- backend/app/api/auth.py | 112 +++++++---- backend/app/api/export.py | 182 ++++++++++-------- backend/app/api/researchers.py | 99 ++++++---- backend/app/core/__init__.py | 0 backend/app/core/body_size.py | 35 ++++ backend/app/core/config.py | 182 ++++++++++++++++++ backend/app/core/error_handlers.py | 67 +++++++ backend/app/core/logging_config.py | 28 +++ backend/app/core/rate_limit.py | 60 ++++++ backend/app/core/security_headers.py | 88 +++++++++ backend/app/db/base.py | 4 + backend/app/db/models.py | 9 + .../db/repositories/publication_repository.py | 20 ++ .../db/repositories/researcher_repository.py | 15 ++ .../app/db/repositories/syncjob_repository.py | 11 ++ backend/app/db/session.py | 10 + backend/app/main.py | 152 +++++++++++---- backend/app/scheduler/sync_scheduler.py | 10 + backend/app/schema/auth.py | 6 + backend/app/schema/export.py | 23 +++ backend/app/schema/publication.py | 4 + backend/app/schema/researcher.py | 35 +++- backend/app/security/api_key.py | 59 +++--- backend/app/security/jwt.py | 125 +++++++++--- backend/app/security/oauth_state.py | 76 ++++++++ backend/app/services/normalizer.py | 6 + backend/app/services/orcid_client.py | 10 + backend/app/services/sword_generator.py | 3 + backend/app/services/sync_service.py | 15 ++ backend/app/services/zip_generator.py | 6 +- backend/app/utils/orcid_validator.py | 19 +- backend/requirements.txt | 8 +- docker-compose.yml | 41 ++-- 37 files changed, 1375 insertions(+), 282 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/body_size.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/error_handlers.py create mode 100644 backend/app/core/logging_config.py create mode 100644 backend/app/core/rate_limit.py create mode 100644 backend/app/core/security_headers.py create mode 100644 backend/app/schema/export.py create mode 100644 backend/app/security/oauth_state.py diff --git a/.gitignore b/.gitignore index 1cc1173..31265ac 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,13 @@ ENV/ # FastAPI / Uvicorn *.pid +# Test / type checkers +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + # --- NODE FRONTEND --- node_modules/ dist/ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..42336ce --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,20 @@ +.env +.env.* +!.env.example +__pycache__/ +*.pyc +*.pyo +*.pyd +*.log +*.sqlite3 +*.db +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ +.git/ +.gitignore +README.md +docs/ +tests/ diff --git a/backend/.env.example b/backend/.env.example index be02114..c6b9fe5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,19 +1,81 @@ -ORCID_CLIENT_ID=123412341234 -ORCID_CLIENT_SECRET=123412341234 - -API_KEY_NAME=X-API-Key -API_KEY_VALUE=123412341234 +# ============================================================ +# ENVIRONMENT +# ============================================================ +ENVIRONMENT=development +DEBUG=false +# ============================================================ +# DATABASE / CACHE +# ============================================================ DATABASE_URL=postgresql://postgres:postgres@db:5432/orcid_db REDIS_URL=redis://redis:6379/0 +# ============================================================ +# BASE URL (uso interno del scheduler) +# ============================================================ BASE_URL=http://localhost:8000/api +# ============================================================ +# CORS — lista blanca estricta separada por comas +# Nunca uses "*" si allow_credentials=true. +# ============================================================ +CORS_ALLOWED_ORIGINS=http://localhost:5173 + +# ============================================================ +# Trusted Hosts — anti Host-header injection (en prod, sé explícito) +# ============================================================ +TRUSTED_HOSTS=* + +# ============================================================ # JWT (login ORCID) -JWT_SECRET=change_me +# Genera un secreto fuerte: `openssl rand -base64 64` +# ============================================================ +JWT_SECRET=change_me_to_a_long_random_value_at_least_32_chars JWT_ALGORITHM=HS256 JWT_EXPIRES_MINUTES=720 +JWT_ISSUER=orcid-sword-backend +JWT_AUDIENCE=orcid-sword-frontend +# ============================================================ +# API key máquina-a-máquina (scheduler interno) +# Genera con: `python -c "import secrets;print(secrets.token_urlsafe(48))"` +# ============================================================ +API_KEY_NAME=X-API-Key +API_KEY_VALUE=replace_with_a_strong_random_value_min_24_chars + +# ============================================================ # ORCID OAuth 3-legged (authorization code) -# Debe coincidir exactamente con el redirect URI configurado en tu app ORCID. -ORCID_REDIRECT_URI=http://localhost:8000/api/auth/orcid/callback \ No newline at end of file +# ============================================================ +ORCID_CLIENT_ID=APP-XXXXXXXXXXXXXXXX +ORCID_CLIENT_SECRET=replace_me +ORCID_REDIRECT_URI=http://localhost:8000/api/auth/orcid/callback +ORCID_OAUTH_STATE_ENABLED=true + +# ============================================================ +# Rate limits (formato slowapi: "/") +# ============================================================ +RATE_LIMIT_DEFAULT=60/minute +RATE_LIMIT_AUTH=10/minute +RATE_LIMIT_SEARCH_ANON=5/minute +RATE_LIMIT_SEARCH_AUTH=30/minute +RATE_LIMIT_EXPORT=20/minute +RATE_LIMIT_SYNC=5/minute + +# ============================================================ +# Tope de tamaños (anti DoS) +# ============================================================ +MAX_ORCID_BATCH=25 +MAX_PUB_IDS_BATCH=500 +MAX_REQUEST_BODY_BYTES=1048576 + +# ============================================================ +# Documentación interactiva (deshabilita en producción si no es necesaria) +# ============================================================ +DOCS_ENABLED=true + +# ============================================================ +# HSTS +# ============================================================ +SECURITY_HSTS_SECONDS=31536000 +SECURITY_HSTS_INCLUDE_SUBDOMAINS=true +SECURITY_HSTS_PRELOAD=false diff --git a/backend/Dockerfile b/backend/Dockerfile index e3f2064..5251f77 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,10 +1,36 @@ -FROM python:3.12-slim +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system --gid 1001 app \ + && useradd --system --uid 1001 --gid app --home /app --shell /usr/sbin/nologin app WORKDIR /app -COPY requirements.txt . +COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY app ./app -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +RUN chown -R app:app /app + +USER app + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/health || exit 1 + +CMD ["uvicorn", "app.main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--proxy-headers", \ + "--forwarded-allow-ips", "*", \ + "--no-server-header"] diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 205cb95..89ecc96 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,64 +1,68 @@ +import logging + import httpx -import os -from pathlib import Path -from dotenv import load_dotenv -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import JSONResponse, RedirectResponse from sqlalchemy.orm import Session +from app.core.config import settings +from app.core.rate_limit import limiter from app.db.models import Researcher from app.db.session import get_db from app.schema.auth import OrcidLoginResponseSchema from app.security.jwt import create_access_token +from app.security.oauth_state import ( + attach_state_cookie, + clear_state_cookie, + generate_state, + validate_state, +) from app.services.orcid_client import ORCIDClient from app.utils.orcid_validator import is_valid_orcid -# Asegura que al ejecutar `uvicorn` local también se carga `backend/.env`. -_ENV_PATH = Path(__file__).resolve().parents[2] / ".env" -load_dotenv(dotenv_path=_ENV_PATH, override=False) - router = APIRouter(prefix="/auth", tags=["auth"]) +logger = logging.getLogger("app.auth") def _extract_display_name(record: dict) -> str | None: person = (record or {}).get("person") or {} name = person.get("name") or {} - given = ((name.get("given-names") or {}).get("value")) if isinstance(name.get("given-names"), dict) else None - family = ((name.get("family-name") or {}).get("value")) if isinstance(name.get("family-name"), dict) else None - full = " ".join([p for p in [given, family] if p]) + given_obj = name.get("given-names") + family_obj = name.get("family-name") + given = given_obj.get("value") if isinstance(given_obj, dict) else None + family = family_obj.get("value") if isinstance(family_obj, dict) else None + full = " ".join(p for p in [given, family] if p) return full or None def _orcid_redirect_uri() -> str: - # Debe coincidir con el `redirect_uri` registrado en tu integración ORCID. - return os.getenv("ORCID_REDIRECT_URI") or "http://localhost:8000/api/auth/orcid/callback" + return settings.ORCID_REDIRECT_URI def _complete_oauth_login(*, code: str, db: Session) -> OrcidLoginResponseSchema: """ - Completa el login OAuth: - 1) intercambio del `code` en ORCID (server-side) - 2) crea/actualiza el investigador - 3) emite nuestro JWT + 1) Intercambia el `code` con ORCID (server-side). + 2) Crea/actualiza el investigador. + 3) Emite el JWT propio. """ - if not code: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing ORCID authorization code") + if not code or len(code) > 256: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid ORCID authorization code") client = ORCIDClient() - redirect_uri = _orcid_redirect_uri() - try: - token_data = client.exchange_authorization_code(code=code, redirect_uri=redirect_uri) + token_data = client.exchange_authorization_code(code=code, redirect_uri=_orcid_redirect_uri()) except httpx.HTTPStatusError as exc: + logger.warning("ORCID token exchange failed: %s", exc.response.status_code) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"ORCID token error ({exc.response.status_code})", - ) - except httpx.TimeoutException: - raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="ORCID timeout") - except Exception: - raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ORCID unavailable") + detail="ORCID token exchange failed", + ) from exc + except httpx.TimeoutException as exc: + raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="ORCID timeout") from exc + except Exception as exc: + logger.exception("Unexpected error during ORCID token exchange") + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="ORCID unavailable") from exc orcid_id = (token_data.get("orcid") or "").strip() if not is_valid_orcid(orcid_id): @@ -66,7 +70,6 @@ def _complete_oauth_login(*, code: str, db: Session) -> OrcidLoginResponseSchema display_name = token_data.get("name") if not display_name: - # Fallback si ORCID no devuelve `name` en el token response. try: record = client.fetch_record(orcid_id) display_name = _extract_display_name(record) @@ -89,21 +92,54 @@ def _complete_oauth_login(*, code: str, db: Session) -> OrcidLoginResponseSchema return OrcidLoginResponseSchema(access_token=token) -@router.get("/orcid/authorize") -def authorize_orcid(): +def complete_oauth_login_response( + *, request: Request, code: str, state: str | None, db: Session +) -> JSONResponse: """ - Inicia el flujo OAuth 3-legged (authorization code) hacia ORCID. + Valida `state`, completa el login y limpia la cookie del state. + Devuelve directamente la JSONResponse (para poder borrar cookie). + """ + validate_state(request, state) + payload = _complete_oauth_login(code=code, db=db) + json_resp = JSONResponse(content=payload.model_dump()) + clear_state_cookie(json_resp) + return json_resp + + +# --------------------------------------------------------- +# ENDPOINT 1: Iniciar flujo OAuth 3-legged hacia ORCID +# --------------------------------------------------------- + +@router.get("/orcid/authorize") +@limiter.limit(settings.RATE_LIMIT_AUTH) +def authorize_orcid(request: Request): + """ + Genera la URL de autorización ORCID y persiste el `state` en cookie + HttpOnly para validarlo en el callback (anti-CSRF). """ client = ORCIDClient() + state = generate_state() if settings.ORCID_OAUTH_STATE_ENABLED else None authorize_url = client.build_authorize_url( redirect_uri=_orcid_redirect_uri(), - # Solo necesitamos el Authenticated iD del usuario. scope="/authenticate", + state=state, ) - return RedirectResponse(authorize_url) + response = RedirectResponse(authorize_url) + if state: + attach_state_cookie(response, state) + return response +# --------------------------------------------------------- +# ENDPOINT 2: Callback OAuth 3-legged desde ORCID +# --------------------------------------------------------- + @router.get("/orcid/callback", response_model=OrcidLoginResponseSchema) -def orcid_callback(code: str, db: Session = Depends(get_db)): - return _complete_oauth_login(code=code, db=db) - +@limiter.limit(settings.RATE_LIMIT_AUTH) +def orcid_callback( + request: Request, + code: str, + state: str | None = None, + db: Session = Depends(get_db), +): + return complete_oauth_login_response(request=request, code=code, state=state, db=db) diff --git a/backend/app/api/export.py b/backend/app/api/export.py index 2152105..7e20fd5 100644 --- a/backend/app/api/export.py +++ b/backend/app/api/export.py @@ -1,115 +1,146 @@ -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import Response -from sqlalchemy.orm import Session +from typing import Iterable, List from uuid import UUID +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request +from fastapi.responses import Response +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.rate_limit import limiter +from app.db.models import Publication, PublicationDownload, Researcher from app.db.session import get_db -from app.db.models import Publication, Researcher, PublicationDownload from app.security.api_key import get_api_key_optional from app.security.jwt import get_optional_current_researcher from app.services.sword_generator import SWORDGenerator from app.services.zip_generator import ZIPGenerator +from app.utils.orcid_validator import ORCID_PATTERN, is_valid_orcid + router = APIRouter(prefix="/export") -def validate_uuid_list(pub_ids: list[str]) -> list[UUID]: - valid_ids = [] - for pid in pub_ids: - try: - valid_ids.append(UUID(pid)) - except Exception: - raise HTTPException( - status_code=400, - detail=f"Invalid publication ID (not UUID): {pid}" - ) - return valid_ids +def _ensure_credentials(api_key: str | None, current: Researcher | None) -> None: + if not api_key and not current: + raise HTTPException(status_code=401, detail="Authentication required") +def _record_downloads(db: Session, current: Researcher, pubs: Iterable[Publication]) -> None: + """ + Inserta marcadores de descarga (researcher_id, publication_id). + + - Resuelve descargas existentes con UNA sola query. + - Solo añade las que faltan. + """ + pub_ids = [p.id for p in pubs] + if not pub_ids: + return + + existing_ids = { + row[0] + for row in ( + db.query(PublicationDownload.publication_id) + .filter( + PublicationDownload.researcher_id == current.id, + PublicationDownload.publication_id.in_(pub_ids), + ) + .all() + ) + } + + new_rows = [ + PublicationDownload(researcher_id=current.id, publication_id=pid) + for pid in pub_ids + if pid not in existing_ids + ] + if new_rows: + db.add_all(new_rows) + db.commit() + + +def _validate_pub_ids(pub_ids: List[UUID]) -> List[UUID]: + if len(pub_ids) > settings.MAX_PUB_IDS_BATCH: + raise HTTPException(status_code=413, detail="Too many publication IDs") + return pub_ids + + +# --------------------------------------------------------- +# ENDPOINT 1: SWORD múltiples publicaciones +# --------------------------------------------------------- + @router.post("/sword/publications") +@limiter.limit(settings.RATE_LIMIT_EXPORT) async def export_multiple_sword( - pub_ids: list[str], + request: Request, + pub_ids: List[UUID] = Body(..., min_length=1, max_length=settings.MAX_PUB_IDS_BATCH), db: Session = Depends(get_db), api_key: str | None = Depends(get_api_key_optional), current: Researcher | None = Depends(get_optional_current_researcher), ): - if not api_key and not current: - raise HTTPException(status_code=401, detail="Missing credentials") - validate_uuid_list(pub_ids) + _ensure_credentials(api_key, current) + _validate_pub_ids(pub_ids) pubs = db.query(Publication).filter(Publication.id.in_(pub_ids)).all() - if not pubs: raise HTTPException(status_code=404, detail="No publications found") researcher = db.query(Researcher).filter_by(id=pubs[0].researcher_id).first() xml_bytes = SWORDGenerator.generate_feed_xml(researcher, pubs) - # Registrar descarga solo si hay usuario logueado if current: - for p in pubs: - exists = ( - db.query(PublicationDownload) - .filter( - PublicationDownload.researcher_id == current.id, - PublicationDownload.publication_id == p.id, - ) - .first() - ) - if not exists: - db.add(PublicationDownload(researcher_id=current.id, publication_id=p.id)) - db.commit() + _record_downloads(db, current, pubs) + return Response(content=xml_bytes, media_type="application/xml") +# --------------------------------------------------------- +# ENDPOINT 2: SWORD por investigador +# --------------------------------------------------------- + @router.get("/sword/researcher/{orcid_id}") +@limiter.limit(settings.RATE_LIMIT_EXPORT) async def export_researcher_sword( - orcid_id: str, + request: Request, + orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN), db: Session = Depends(get_db), api_key: str | None = Depends(get_api_key_optional), current: Researcher | None = Depends(get_optional_current_researcher), ): - if not api_key and not current: - raise HTTPException(status_code=401, detail="Missing credentials") + _ensure_credentials(api_key, current) + if not is_valid_orcid(orcid_id): + raise HTTPException(status_code=400, detail="Invalid ORCID iD") + researcher = db.query(Researcher).filter_by(orcid_id=orcid_id).first() if not researcher: raise HTTPException(status_code=404, detail="Researcher not found") pubs = db.query(Publication).filter_by(researcher_id=researcher.id).all() - if not pubs: raise HTTPException(status_code=404, detail="No publications found for this researcher") xml_bytes = SWORDGenerator.generate_feed_xml(researcher, pubs) if current: - for p in pubs: - exists = ( - db.query(PublicationDownload) - .filter( - PublicationDownload.researcher_id == current.id, - PublicationDownload.publication_id == p.id, - ) - .first() - ) - if not exists: - db.add(PublicationDownload(researcher_id=current.id, publication_id=p.id)) - db.commit() + _record_downloads(db, current, pubs) + return Response(content=xml_bytes, media_type="application/xml") +# --------------------------------------------------------- +# ENDPOINT 3: ZIP múltiples publicaciones +# --------------------------------------------------------- + @router.post("/zip/publications") +@limiter.limit(settings.RATE_LIMIT_EXPORT) async def export_multiple_zip( - pub_ids: list[str], + request: Request, + pub_ids: List[UUID] = Body(..., min_length=1, max_length=settings.MAX_PUB_IDS_BATCH), db: Session = Depends(get_db), api_key: str | None = Depends(get_api_key_optional), current: Researcher | None = Depends(get_optional_current_researcher), ): - if not api_key and not current: - raise HTTPException(status_code=401, detail="Missing credentials") - validate_uuid_list(pub_ids) + _ensure_credentials(api_key, current) + _validate_pub_ids(pub_ids) pubs = db.query(Publication).filter(Publication.id.in_(pub_ids)).all() - if not pubs: raise HTTPException(status_code=404, detail="No publications found") @@ -117,51 +148,38 @@ async def export_multiple_zip( zip_bytes = ZIPGenerator.generate_zip(researcher, pubs) if current: - for p in pubs: - exists = ( - db.query(PublicationDownload) - .filter( - PublicationDownload.researcher_id == current.id, - PublicationDownload.publication_id == p.id, - ) - .first() - ) - if not exists: - db.add(PublicationDownload(researcher_id=current.id, publication_id=p.id)) - db.commit() + _record_downloads(db, current, pubs) + return Response(content=zip_bytes, media_type="application/zip") +# --------------------------------------------------------- +# ENDPOINT 4: ZIP por investigador +# --------------------------------------------------------- + @router.get("/zip/researcher/{orcid_id}") +@limiter.limit(settings.RATE_LIMIT_EXPORT) async def export_researcher_zip( - orcid_id: str, + request: Request, + orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN), db: Session = Depends(get_db), api_key: str | None = Depends(get_api_key_optional), current: Researcher | None = Depends(get_optional_current_researcher), ): - if not api_key and not current: - raise HTTPException(status_code=401, detail="Missing credentials") + _ensure_credentials(api_key, current) + if not is_valid_orcid(orcid_id): + raise HTTPException(status_code=400, detail="Invalid ORCID iD") + researcher = db.query(Researcher).filter_by(orcid_id=orcid_id).first() if not researcher: raise HTTPException(status_code=404, detail="Researcher not found") pubs = db.query(Publication).filter_by(researcher_id=researcher.id).all() - if not pubs: raise HTTPException(status_code=404, detail="No publications found for this researcher") zip_bytes = ZIPGenerator.generate_zip(researcher, pubs) if current: - for p in pubs: - exists = ( - db.query(PublicationDownload) - .filter( - PublicationDownload.researcher_id == current.id, - PublicationDownload.publication_id == p.id, - ) - .first() - ) - if not exists: - db.add(PublicationDownload(researcher_id=current.id, publication_id=p.id)) - db.commit() + _record_downloads(db, current, pubs) + return Response(content=zip_bytes, media_type="application/zip") diff --git a/backend/app/api/researchers.py b/backend/app/api/researchers.py index c13b8e3..c8df04d 100644 --- a/backend/app/api/researchers.py +++ b/backend/app/api/researchers.py @@ -2,11 +2,14 @@ from datetime import datetime from typing import List import httpx -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Path, Request from sqlalchemy.orm import Session -from app.db.models import Publication, Researcher +from app.core.config import settings +from app.core.rate_limit import limiter +from app.db.models import Publication, PublicationDownload, Researcher from app.db.session import get_db +from app.schema.publication import PublicationSchema from app.schema.researcher import ( ResearcherBatchSearchRequestSchema, ResearcherBatchSearchResponseSchema, @@ -14,18 +17,15 @@ from app.schema.researcher import ( ResearcherStatsSchema, ResearcherWithPublicationsSchema, ) +from app.security.jwt import get_current_researcher, get_optional_current_researcher from app.services.normalizer import PublicationNormalizer -from app.services.orcid_client import get_display_name, get_works_summary, get_work_detail -from app.schema.publication import PublicationSchema -from app.db.models import PublicationDownload -from app.security.jwt import get_optional_current_researcher +from app.services.orcid_client import get_display_name, get_work_detail, get_works_summary +from app.utils.orcid_validator import ORCID_PATTERN, is_valid_orcid + router = APIRouter(prefix="/researchers", tags=["researchers"]) -# --------------------------------------------------------- -# Función auxiliar: detectar si una publicación ha cambiado -# --------------------------------------------------------- def publication_changed(existing: Publication, data: dict) -> bool: fields = [ "title", "subtitle", "type", "journal", @@ -33,18 +33,13 @@ def publication_changed(existing: Publication, data: dict) -> bool: "doi", "url", "short_description", "citation_type", "citation_value", "language_code", "country", - "external_ids", "contributors" + "external_ids", "contributors", ] - - for f in fields: - if getattr(existing, f) != data[f]: - return True - return False + return any(getattr(existing, f) != data[f] for f in fields) def build_researcher_stats(publications: list) -> ResearcherStatsSchema: publication_types: dict[str, int] = {} - for publication in publications: pub_type = getattr(publication, "type", None) or "unknown" publication_types[pub_type] = publication_types.get(pub_type, 0) + 1 @@ -98,7 +93,7 @@ def _upsert_researcher_publications( "doi", "url", "short_description", "citation_type", "citation_value", "language_code", "country", - "external_ids", "contributors" + "external_ids", "contributors", ]: setattr(existing, field, data[field]) existing.last_modified = datetime.utcnow() @@ -142,12 +137,17 @@ def _decorate_downloaded_by_me( out: List[PublicationSchema] = [] for p in publications: out.append( - PublicationSchema.model_validate(p).model_copy(update={"downloaded_by_me": p.id in downloaded_ids}) + PublicationSchema.model_validate(p).model_copy( + update={"downloaded_by_me": p.id in downloaded_ids} + ) ) return out def build_search_response(orcid_id: str, db: Session, current: Researcher | None) -> ResearcherWithPublicationsSchema: + if not is_valid_orcid(orcid_id): + raise HTTPException(status_code=400, detail="Invalid ORCID iD") + researcher = db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first() if not researcher: researcher = Researcher( @@ -159,10 +159,6 @@ def build_search_response(orcid_id: str, db: Session, current: Researcher | None db.add(researcher) db.flush() - # Si todavía no conocemos el nombre del investigador (por ejemplo, recién - # creado al sincronizarse desde el buscador), lo resolvemos contra el - # endpoint `/record` público de ORCID. No tocamos un nombre ya existente - # para no pisar valores establecidos por el flujo de autenticación. if not researcher.name: display_name = get_display_name(orcid_id) if display_name: @@ -185,10 +181,27 @@ def build_search_response(orcid_id: str, db: Session, current: Researcher | None # --------------------------------------------------------- -# ENDPOINT 1: SEARCH + SYNC (sin contadores) +# ENDPOINT 1: SEARCH + SYNC # --------------------------------------------------------- -@router.post("/search", response_model=ResearcherBatchSearchResponseSchema, response_model_exclude_none=True) + +def _search_rate_limit(request: Request) -> str: + """ + Aplica un límite distinto si el usuario está autenticado. + Como SlowAPI evalúa el decorador antes de las dependencias, devolvemos + el límite más restrictivo y subimos sólo si hay token (state.researcher). + """ + researcher = getattr(request.state, "researcher", None) + return settings.RATE_LIMIT_SEARCH_AUTH if researcher else settings.RATE_LIMIT_SEARCH_ANON + + +@router.post( + "/search", + response_model=ResearcherBatchSearchResponseSchema, + response_model_exclude_none=True, +) +@limiter.limit(_search_rate_limit) def search_and_sync_researchers( + request: Request, payload: ResearcherBatchSearchRequestSchema, db: Session = Depends(get_db), current: Researcher | None = Depends(get_optional_current_researcher), @@ -196,26 +209,33 @@ def search_and_sync_researchers( results: List[ResearcherWithPublicationsSchema] = [] errors: List[ResearcherSearchErrorSchema] = [] - # Evita llamadas duplicadas a ORCID conservando el orden de entrada. unique_orcid_ids = list(dict.fromkeys(payload.orcid_ids)) for orcid_id in unique_orcid_ids: try: results.append(build_search_response(orcid_id, db, current)) + except HTTPException as exc: + db.rollback() + errors.append( + ResearcherSearchErrorSchema( + orcid_id=orcid_id, + detail=str(exc.detail), + ) + ) except httpx.HTTPStatusError as exc: db.rollback() errors.append( ResearcherSearchErrorSchema( orcid_id=orcid_id, - detail=f"ORCID devolvió {exc.response.status_code} para {orcid_id}.", + detail=f"ORCID returned {exc.response.status_code}", ) ) - except Exception as exc: + except Exception: db.rollback() errors.append( ResearcherSearchErrorSchema( orcid_id=orcid_id, - detail=str(exc), + detail="Unexpected error while processing ORCID iD", ) ) @@ -228,14 +248,24 @@ def search_and_sync_researchers( # --------------------------------------------------------- -# ENDPOINT 2: SYNC COMPLETO (con contadores + status) +# ENDPOINT 2: SYNC COMPLETO (requiere autenticación) # --------------------------------------------------------- -@router.post("/{orcid_id}/sync", response_model=ResearcherWithPublicationsSchema, response_model_exclude_none=True) + +@router.post( + "/{orcid_id}/sync", + response_model=ResearcherWithPublicationsSchema, + response_model_exclude_none=True, +) +@limiter.limit(settings.RATE_LIMIT_SYNC) def sync_researcher( - orcid_id: str, + request: Request, + orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN), db: Session = Depends(get_db), - current: Researcher | None = Depends(get_optional_current_researcher), + current: Researcher = Depends(get_current_researcher), ): + if not is_valid_orcid(orcid_id): + raise HTTPException(status_code=400, detail="Invalid ORCID iD") + researcher = db.query(Researcher).filter_by(orcid_id=orcid_id).first() if not researcher: raise HTTPException(status_code=404, detail="Researcher not found") @@ -244,7 +274,6 @@ def sync_researcher( groups = works.get("group", []) publications_output = [] - new_count = 0 updated_count = 0 unchanged_count = 0 @@ -277,21 +306,17 @@ def sync_researcher( if existing: if publication_changed(existing, data): - # updated for field in data: setattr(existing, field, data[field]) existing.last_modified = datetime.utcnow() existing.status = "updated" updated_count += 1 else: - # unchanged existing.status = "unchanged" unchanged_count += 1 pub = existing - else: - # new pub = Publication( researcher_id=researcher.id, **data, diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/body_size.py b/backend/app/core/body_size.py new file mode 100644 index 0000000..323e01f --- /dev/null +++ b/backend/app/core/body_size.py @@ -0,0 +1,35 @@ +""" +Middleware que limita el tamaño máximo del cuerpo de la petición. + +Evita ataques de agotamiento de memoria/CPU enviando bodies enormes a +endpoints POST. Se aplica antes de que FastAPI deserialice el JSON. +""" + +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + + +class BodySizeLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, *, max_bytes: int): + super().__init__(app) + self._max_bytes = max_bytes + + async def dispatch(self, request: Request, call_next) -> Response: + content_length = request.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > self._max_bytes: + return JSONResponse( + status_code=413, + content={"detail": "Request body too large"}, + ) + except ValueError: + return JSONResponse( + status_code=400, + content={"detail": "Invalid Content-Length header"}, + ) + + return await call_next(request) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..f69d92e --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,182 @@ +""" +Configuración tipada y validada del backend. + +Centraliza la lectura de variables de entorno, valida secretos críticos al +arranque y evita fallbacks inseguros (p. ej. JWT_SECRET="change_me") en +entornos productivos. +""" + +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path +from typing import List, Literal +from urllib.parse import urlparse + +from dotenv import load_dotenv +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +_ENV_PATH = Path(__file__).resolve().parents[2] / ".env" +load_dotenv(dotenv_path=_ENV_PATH, override=False) + + +def _split_csv(value: str | List[str] | None) -> List[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(v).strip().rstrip("/") for v in value if str(v).strip()] + return [v.strip().rstrip("/") for v in value.split(",") if v.strip()] + + +class Settings(BaseSettings): + """ + Settings inmutables para toda la aplicación. + + En `production` se aplican validaciones más estrictas: + - JWT_SECRET no puede ser un valor débil ni por defecto. + - CORS_ALLOWED_ORIGINS no puede contener "*". + - Se exige ORCID_CLIENT_ID/SECRET y API_KEY_VALUE. + """ + + model_config = SettingsConfigDict( + env_file=str(_ENV_PATH), + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + ENVIRONMENT: Literal["development", "staging", "production"] = "development" + DEBUG: bool = False + + DATABASE_URL: str = Field(...) + REDIS_URL: str | None = None + BASE_URL: str = "http://localhost:8000/api" + + JWT_SECRET: str = Field(...) + JWT_ALGORITHM: str = "HS256" + JWT_EXPIRES_MINUTES: int = 720 + JWT_ISSUER: str = "orcid-sword-backend" + JWT_AUDIENCE: str = "orcid-sword-frontend" + + API_KEY_NAME: str = "X-API-Key" + API_KEY_VALUE: str = Field(...) + + ORCID_CLIENT_ID: str = Field(...) + ORCID_CLIENT_SECRET: str = Field(...) + ORCID_REDIRECT_URI: str = "http://localhost:8000/api/auth/orcid/callback" + ORCID_OAUTH_STATE_ENABLED: bool = True + ORCID_OAUTH_STATE_COOKIE: str = "orcid_oauth_state" + ORCID_OAUTH_STATE_TTL_SECONDS: int = 600 + + CORS_ALLOWED_ORIGINS: List[str] = Field(default_factory=list) + + TRUSTED_HOSTS: List[str] = Field(default_factory=lambda: ["*"]) + + RATE_LIMIT_DEFAULT: str = "60/minute" + RATE_LIMIT_AUTH: str = "10/minute" + RATE_LIMIT_SEARCH_ANON: str = "5/minute" + RATE_LIMIT_SEARCH_AUTH: str = "30/minute" + RATE_LIMIT_EXPORT: str = "20/minute" + RATE_LIMIT_SYNC: str = "5/minute" + + MAX_ORCID_BATCH: int = 25 + MAX_PUB_IDS_BATCH: int = 500 + MAX_REQUEST_BODY_BYTES: int = 1_048_576 # 1 MiB + + DOCS_ENABLED: bool = True + + SECURITY_HSTS_SECONDS: int = 31_536_000 + SECURITY_HSTS_INCLUDE_SUBDOMAINS: bool = True + SECURITY_HSTS_PRELOAD: bool = False + + @field_validator("CORS_ALLOWED_ORIGINS", mode="before") + @classmethod + def _parse_cors(cls, v): + return _split_csv(v) + + @field_validator("TRUSTED_HOSTS", mode="before") + @classmethod + def _parse_trusted_hosts(cls, v): + parsed = _split_csv(v) if not isinstance(v, list) else v + return parsed or ["*"] + + @model_validator(mode="after") + def _validate_security(self) -> "Settings": + if self.ENVIRONMENT == "production": + weak = {"change_me", "changeme", "secret", "password", ""} + if self.JWT_SECRET.strip().lower() in weak: + raise ValueError( + "JWT_SECRET es débil o está sin configurar. " + "Define un secreto aleatorio fuerte (>= 32 bytes)." + ) + if len(self.JWT_SECRET) < 32: + raise ValueError( + "JWT_SECRET debe tener al menos 32 caracteres en producción." + ) + if "*" in self.CORS_ALLOWED_ORIGINS: + raise ValueError( + "CORS_ALLOWED_ORIGINS no puede contener '*' en producción." + ) + if not self.CORS_ALLOWED_ORIGINS: + raise ValueError( + "CORS_ALLOWED_ORIGINS debe definirse explícitamente en producción." + ) + if not self.API_KEY_VALUE or len(self.API_KEY_VALUE) < 24: + raise ValueError( + "API_KEY_VALUE debe tener al menos 24 caracteres en producción." + ) + if self.TRUSTED_HOSTS == ["*"]: + raise ValueError( + "TRUSTED_HOSTS debe definirse explícitamente en producción." + ) + + for origin in self.CORS_ALLOWED_ORIGINS: + parsed = urlparse(origin) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"Origen CORS inválido: {origin!r}") + + return self + + @property + def is_production(self) -> bool: + return self.ENVIRONMENT == "production" + + @property + def docs_url(self) -> str | None: + return "/docs" if self.DOCS_ENABLED else None + + @property + def redoc_url(self) -> str | None: + return "/redoc" if self.DOCS_ENABLED else None + + @property + def openapi_url(self) -> str | None: + return "/openapi.json" if self.DOCS_ENABLED else None + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """ + Devuelve la instancia única de configuración. + + Se cachea para no releer entorno/archivos en cada request. + """ + return Settings() # type: ignore[call-arg] + + +settings = get_settings() + + +def reload_settings_for_tests() -> Settings: + """ + Helper para tests: invalida la caché y recarga settings. + """ + get_settings.cache_clear() + globals()["settings"] = get_settings() + return globals()["settings"] + + +__all__ = ["Settings", "get_settings", "reload_settings_for_tests", "settings"] diff --git a/backend/app/core/error_handlers.py b/backend/app/core/error_handlers.py new file mode 100644 index 0000000..52803fc --- /dev/null +++ b/backend/app/core/error_handlers.py @@ -0,0 +1,67 @@ +""" +Manejadores de errores que NO filtran información sensible. + +- En producción, las excepciones no controladas devuelven un mensaje genérico. +- En desarrollo, se incluye `type` para depurar (sin trazas). +- Errores de validación se devuelven con 422 estándar de FastAPI. +""" + +from __future__ import annotations + +import logging +import uuid + +from fastapi import HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError + +from app.core.config import settings + + +logger = logging.getLogger("app.error") + + +async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=getattr(exc, "headers", None), + ) + + +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + safe_errors = [] + for err in exc.errors(): + safe_errors.append( + { + "loc": err.get("loc"), + "msg": err.get("msg"), + "type": err.get("type"), + } + ) + return JSONResponse(status_code=422, content={"detail": safe_errors}) + + +async def sqlalchemy_exception_handler( + request: Request, exc: SQLAlchemyError +) -> JSONResponse: + error_id = str(uuid.uuid4()) + logger.exception("DB error [%s] on %s %s", error_id, request.method, request.url.path) + return JSONResponse( + status_code=500, + content={"detail": "Database error", "error_id": error_id}, + ) + + +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + error_id = str(uuid.uuid4()) + logger.exception( + "Unhandled error [%s] on %s %s", error_id, request.method, request.url.path + ) + payload: dict = {"detail": "Internal server error", "error_id": error_id} + if not settings.is_production and settings.DEBUG: + payload["type"] = exc.__class__.__name__ + return JSONResponse(status_code=500, content=payload) diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py new file mode 100644 index 0000000..0dd3f7c --- /dev/null +++ b/backend/app/core/logging_config.py @@ -0,0 +1,28 @@ +""" +Configuración de logging estructurada y minimalista. + +- Formatea con timestamp, nivel y logger. +- En producción usa nivel INFO; en desarrollo DEBUG. +- Silencia logs ruidosos de librerías externas para no filtrar headers. +""" + +from __future__ import annotations + +import logging + +from app.core.config import settings + + +_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s :: %(message)s" + + +def configure_logging() -> None: + level = logging.DEBUG if settings.DEBUG else logging.INFO + + logging.basicConfig(level=level, format=_LOG_FORMAT) + + for noisy in ("httpx", "httpcore", "sqlalchemy.engine.Engine"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + logging.getLogger("uvicorn.error").setLevel(level) + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..d216609 --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,60 @@ +""" +Rate limiting basado en SlowAPI. + +- Usa Redis como backend si `REDIS_URL` está definido (compartido entre workers). +- Cae a memoria local en desarrollo si Redis no está disponible. +- Identifica al cliente por IP y, cuando hay JWT, también por `sub` (orcid_id), + para que un atacante autenticado no comparta cupo con su IP. +""" + +from __future__ import annotations + +from typing import Optional + +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address +from starlette.requests import Request +from starlette.responses import JSONResponse + +from app.core.config import settings + + +def _key_func(request: Request) -> str: + """ + Devuelve la clave de rate limit para el request. + + - Si hay un investigador autenticado en el state, usa su orcid_id. + - En caso contrario, usa la IP remota. + """ + researcher = getattr(request.state, "researcher", None) + if researcher is not None: + return f"user:{getattr(researcher, 'orcid_id', None) or researcher.id}" + return f"ip:{get_remote_address(request)}" + + +def _build_limiter() -> Limiter: + storage_uri: Optional[str] = settings.REDIS_URL + return Limiter( + key_func=_key_func, + default_limits=[settings.RATE_LIMIT_DEFAULT], + storage_uri=storage_uri, + headers_enabled=True, + strategy="fixed-window-elastic-expiry", + ) + + +limiter = _build_limiter() + + +def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + """ + Respuesta uniforme cuando se supera el límite. + + No revela límites internos exactos para reducir oráculo a atacantes. + """ + return JSONResponse( + status_code=429, + content={"detail": "Too many requests, slow down."}, + headers={"Retry-After": "60"}, + ) diff --git a/backend/app/core/security_headers.py b/backend/app/core/security_headers.py new file mode 100644 index 0000000..9de3eff --- /dev/null +++ b/backend/app/core/security_headers.py @@ -0,0 +1,88 @@ +""" +Middleware de cabeceras de seguridad HTTP. + +Aplica un perfil seguro por defecto: +- Strict-Transport-Security (HSTS) — fuerza HTTPS en navegadores compatibles. +- X-Content-Type-Options: nosniff +- X-Frame-Options: DENY (clickjacking) +- Referrer-Policy: strict-origin-when-cross-origin +- Permissions-Policy: bloquea APIs sensibles por defecto +- Cross-Origin-Opener-Policy / Resource-Policy: aislamiento del navegador +- Content-Security-Policy laxa para Swagger/OpenAPI (CDN), restrictiva para el resto. + +NOTA: El frontend SPA tiene su propia CSP en su servidor. Aquí +endurecemos lo que sirve el backend (JSON, XML, ZIP, /docs, /redoc, etc.). +""" + +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from app.core.config import Settings + + +_DOCS_PATHS = ("/docs", "/redoc", "/openapi.json") + +_BASE_CSP = ( + "default-src 'none'; " + "frame-ancestors 'none'; " + "base-uri 'none'; " + "form-action 'none'" +) + +_SWAGGER_CSP = ( + "default-src 'self'; " + "img-src 'self' data: https://fastapi.tiangolo.com; " + "script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; " + "style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; " + "font-src 'self' data: https://cdn.jsdelivr.net; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" +) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """ + Inserta cabeceras de seguridad en cada respuesta. + """ + + def __init__(self, app, settings: Settings): + super().__init__(app) + self._settings = settings + + async def dispatch(self, request: Request, call_next) -> Response: + response: Response = await call_next(request) + + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=(), payment=(), usb=(), " + "accelerometer=(), gyroscope=(), magnetometer=(), interest-cohort=()", + ) + response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + response.headers.setdefault("Cross-Origin-Resource-Policy", "same-site") + response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none") + + if request.url.path in _DOCS_PATHS: + response.headers.setdefault("Content-Security-Policy", _SWAGGER_CSP) + else: + response.headers.setdefault("Content-Security-Policy", _BASE_CSP) + + if request.url.scheme == "https" or self._settings.is_production: + hsts = f"max-age={self._settings.SECURITY_HSTS_SECONDS}" + if self._settings.SECURITY_HSTS_INCLUDE_SUBDOMAINS: + hsts += "; includeSubDomains" + if self._settings.SECURITY_HSTS_PRELOAD: + hsts += "; preload" + response.headers.setdefault("Strict-Transport-Security", hsts) + + response.headers.pop("Server", None) + response.headers.pop("X-Powered-By", None) + + return response diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 59be703..d350806 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -1,3 +1,7 @@ from sqlalchemy.orm import declarative_base +# --------------------------------------------------------- +# Base de datos +# --------------------------------------------------------- + Base = declarative_base() diff --git a/backend/app/db/models.py b/backend/app/db/models.py index ae61527..e4138b4 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -6,6 +6,9 @@ from datetime import datetime from app.db.session import Base +# --------------------------------------------------------- +# Modelo de investigador +# --------------------------------------------------------- class Researcher(Base): __tablename__ = "researchers" @@ -18,6 +21,9 @@ class Researcher(Base): publications = relationship("Publication", back_populates="researcher", cascade="all, delete-orphan") +# --------------------------------------------------------- +# Modelo de publicación +# --------------------------------------------------------- class Publication(Base): __tablename__ = "publications" @@ -65,6 +71,9 @@ class Publication(Base): # Legacy: descargado global (deprecado). Mantener por compatibilidad de DB. downloaded = Column(Boolean, nullable=False, default=False) +# --------------------------------------------------------- +# Modelo de descarga de publicación +# --------------------------------------------------------- class PublicationDownload(Base): """ diff --git a/backend/app/db/repositories/publication_repository.py b/backend/app/db/repositories/publication_repository.py index 590010b..ca23694 100644 --- a/backend/app/db/repositories/publication_repository.py +++ b/backend/app/db/repositories/publication_repository.py @@ -1,8 +1,16 @@ from sqlalchemy.orm import Session from app.db.models import Publication +# --------------------------------------------------------- +# Repositorio de publicaciones +# --------------------------------------------------------- + class PublicationRepository: + # --------------------------------------------------------- + # Función auxiliar: obtener publicación por put_code + # --------------------------------------------------------- + @staticmethod def get_by_put_code(db: Session, researcher_id: str, put_code: int): """ @@ -17,6 +25,10 @@ class PublicationRepository: .first() ) + # --------------------------------------------------------- + # Función auxiliar: crear una nueva publicación + # --------------------------------------------------------- + @staticmethod def create(db: Session, researcher_id: str, data: dict): """ @@ -37,6 +49,10 @@ class PublicationRepository: db.refresh(pub) return pub + # --------------------------------------------------------- + # Función auxiliar: actualizar una publicación existente + # --------------------------------------------------------- + @staticmethod def update(db: Session, publication: Publication, data: dict): """ @@ -53,6 +69,10 @@ class PublicationRepository: db.refresh(publication) return publication + # --------------------------------------------------------- + # Función auxiliar: listar publicaciones de un investigador + # --------------------------------------------------------- + @staticmethod def list_by_researcher(db: Session, researcher_id: str): """ diff --git a/backend/app/db/repositories/researcher_repository.py b/backend/app/db/repositories/researcher_repository.py index 4aba7af..1b8c3b2 100644 --- a/backend/app/db/repositories/researcher_repository.py +++ b/backend/app/db/repositories/researcher_repository.py @@ -2,13 +2,24 @@ from sqlalchemy.orm import Session from app.db.models import Researcher from sqlalchemy.sql import func +# --------------------------------------------------------- +# Repositorio de investigadores +# --------------------------------------------------------- class ResearcherRepository: + # --------------------------------------------------------- + # Función auxiliar: obtener investigador por ORCID ID + # --------------------------------------------------------- + @staticmethod def get_by_orcid(db: Session, orcid_id: str): return db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first() + # --------------------------------------------------------- + # Función auxiliar: crear un nuevo investigador + # --------------------------------------------------------- + @staticmethod def create(db: Session, orcid_id: str, name: str = None): researcher = Researcher(orcid_id=orcid_id, name=name) @@ -17,6 +28,10 @@ class ResearcherRepository: db.refresh(researcher) return researcher + # --------------------------------------------------------- + # Función auxiliar: actualizar la última sincronización + # --------------------------------------------------------- + @staticmethod def update_last_sync(db: Session, researcher: Researcher): researcher.last_sync_at = func.now() diff --git a/backend/app/db/repositories/syncjob_repository.py b/backend/app/db/repositories/syncjob_repository.py index 1cb00a1..7860789 100644 --- a/backend/app/db/repositories/syncjob_repository.py +++ b/backend/app/db/repositories/syncjob_repository.py @@ -2,9 +2,16 @@ from sqlalchemy.orm import Session from app.db.models import SyncJob from sqlalchemy.sql import func +# --------------------------------------------------------- +# Repositorio de trabajos de sincronización +# --------------------------------------------------------- class SyncJobRepository: + # --------------------------------------------------------- + # Función auxiliar: iniciar un nuevo trabajo de sincronización + # --------------------------------------------------------- + @staticmethod def start_job(db: Session, researcher_id: str): job = SyncJob( @@ -17,6 +24,10 @@ class SyncJobRepository: db.refresh(job) return job + # --------------------------------------------------------- + # Función auxiliar: finalizar un trabajo de sincronización + # --------------------------------------------------------- + @staticmethod def finish_job(db: Session, job: SyncJob, new_records: int, updated_records: int): job.status = "finished" diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 37b271e..afdd051 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -9,6 +9,7 @@ load_dotenv() # ----------------------------- # DATABASE URL # ----------------------------- + DATABASE_URL = os.getenv("DATABASE_URL") engine = create_engine( @@ -29,6 +30,7 @@ Base = declarative_base() # ----------------------------- # DB SESSION DEPENDENCY # ----------------------------- + def get_db(): db = SessionLocal() try: @@ -40,17 +42,25 @@ def get_db(): # ----------------------------- # INIT DB (CREA TABLAS) # ----------------------------- + def init_db(): + # Importa modelos para que SQLAlchemy los registre + import app.db.models # noqa # Crea todas las tablas si no existen + Base.metadata.create_all(bind=engine) # Pequeñas migraciones "best-effort" para entornos sin Alembic. # (create_all no altera tablas existentes) + _ensure_columns() +# --------------------------------------------------------- +# Función auxiliar: asegurar columnas existentes +# --------------------------------------------------------- def _ensure_columns(): insp = inspect(engine) diff --git a/backend/app/main.py b/backend/app/main.py index 1e5d6c8..d39e246 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,68 +1,154 @@ -from fastapi import Depends, FastAPI +""" +Entry point del backend FastAPI. + +Aplica un perfil de seguridad por defecto: +- Configuración tipada (Pydantic Settings) que falla rápido en producción. +- TrustedHostMiddleware (anti Host-header injection). +- CORS con lista blanca estricta (sin `*`). +- Body size limit (anti DoS por payload). +- Cabeceras de seguridad HTTP. +- Rate limiting (slowapi) con backend Redis si está configurado. +- Error handlers que NO filtran trazas ni internals. +""" + +from __future__ import annotations + +import logging + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from slowapi.errors import RateLimitExceeded +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session -from app.db.session import init_db -from app.db.session import get_db -from app.api.researchers import router as researchers_router +from app.api.auth import complete_oauth_login_response, router as auth_router from app.api.export import router as export_router -from app.api.auth import router as auth_router -from app.api.auth import _complete_oauth_login -from app.schema.auth import OrcidLoginResponseSchema +from app.api.researchers import router as researchers_router +from app.core.body_size import BodySizeLimitMiddleware +from app.core.config import settings +from app.core.error_handlers import ( + http_exception_handler, + sqlalchemy_exception_handler, + unhandled_exception_handler, + validation_exception_handler, +) +from app.core.logging_config import configure_logging +from app.core.rate_limit import limiter, rate_limit_exceeded_handler +from app.core.security_headers import SecurityHeadersMiddleware +from app.db.session import get_db, init_db from app.scheduler.sync_scheduler import start_scheduler +from app.schema.auth import OrcidLoginResponseSchema + + +configure_logging() +logger = logging.getLogger("app.main") -# --------------------------------------------------------- -# Crear instancia principal de FastAPI -# --------------------------------------------------------- app = FastAPI( title="ORCID SWORD Backend", description="Backend para sincronización ORCID y exportación SWORD", - version="1.0.0" + version="1.0.0", + docs_url=settings.docs_url, + redoc_url=settings.redoc_url, + openapi_url=settings.openapi_url, ) # --------------------------------------------------------- -# Crear tablas al iniciar la aplicación +# Middlewares (orden importa: el último añadido es el más externo) # --------------------------------------------------------- + +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) + +app.add_middleware(SecurityHeadersMiddleware, settings=settings) + +app.add_middleware( + BodySizeLimitMiddleware, + max_bytes=settings.MAX_REQUEST_BODY_BYTES, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=[ + "Authorization", + "Content-Type", + "Accept", + "Origin", + "X-Requested-With", + settings.API_KEY_NAME, + ], + expose_headers=["Content-Disposition", "X-RateLimit-Remaining", "X-RateLimit-Reset"], + max_age=600, +) + +app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=settings.TRUSTED_HOSTS, +) + + +# --------------------------------------------------------- +# Exception handlers +# --------------------------------------------------------- + +app.add_exception_handler(HTTPException, http_exception_handler) +app.add_exception_handler(RequestValidationError, validation_exception_handler) +app.add_exception_handler(SQLAlchemyError, sqlalchemy_exception_handler) +app.add_exception_handler(Exception, unhandled_exception_handler) + + +# --------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------- + @app.on_event("startup") -def startup_event(): - init_db() # 🔥 CREA TABLAS - start_scheduler() # 🔥 INICIA SCHEDULER +def on_startup() -> None: + init_db() + start_scheduler() + logger.info( + "Backend ready (env=%s, docs=%s)", + settings.ENVIRONMENT, + bool(settings.DOCS_ENABLED), + ) # --------------------------------------------------------- # Healthcheck # --------------------------------------------------------- + @app.get("/health") -def health(): +def health() -> dict[str, str]: return {"status": "ok"} +# --------------------------------------------------------- +# Alias del callback OAuth (mismo flujo, mismo endurecimiento) +# --------------------------------------------------------- + @app.get("/callback", response_model=OrcidLoginResponseSchema) -def oauth_callback_root(code: str, db: Session = Depends(get_db)): +def oauth_callback_root( + request: Request, + code: str, + state: str | None = None, + db: Session = Depends(get_db), +): """ - Alias para probar redirect URIs como `https://127.0.0.1/callback` en local. - Intercambia el code con ORCID y emite el JWT. + Alias para integraciones que registran un redirect_uri tipo + `https:///callback` en ORCID. """ - return _complete_oauth_login(code=code, db=db) + return complete_oauth_login_response(request=request, code=code, state=state, db=db) # --------------------------------------------------------- -# Registrar routers +# Routers # --------------------------------------------------------- + app.include_router(researchers_router, prefix="/api") app.include_router(export_router, prefix="/api") app.include_router(auth_router, prefix="/api") - - -# --------------------------------------------------------- -# CORS -# --------------------------------------------------------- -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # en producción limitar - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) diff --git a/backend/app/scheduler/sync_scheduler.py b/backend/app/scheduler/sync_scheduler.py index 586e054..69ce594 100644 --- a/backend/app/scheduler/sync_scheduler.py +++ b/backend/app/scheduler/sync_scheduler.py @@ -9,9 +9,16 @@ import os # Cargar variables del .env load_dotenv() +# --------------------------------------------------------- +# Variables de entorno +# --------------------------------------------------------- + API_KEY = os.getenv("API_KEY_VALUE") BASE_URL = os.getenv("BASE_URL") +# --------------------------------------------------------- +# Función auxiliar: ejecutar sincronización mensual +# --------------------------------------------------------- def run_monthly_sync(): db = SessionLocal() @@ -36,6 +43,9 @@ def run_monthly_sync(): db.close() +# --------------------------------------------------------- +# Función auxiliar: iniciar el scheduler +# --------------------------------------------------------- def start_scheduler(): scheduler = BackgroundScheduler() diff --git a/backend/app/schema/auth.py b/backend/app/schema/auth.py index 869fde1..bd09626 100644 --- a/backend/app/schema/auth.py +++ b/backend/app/schema/auth.py @@ -1,11 +1,17 @@ from pydantic import BaseModel, Field +# --------------------------------------------------------- +# Modelo de solicitud de login OAuth +# --------------------------------------------------------- class OrcidLoginRequestSchema(BaseModel): # `code` is the authorization code returned by ORCID OAuth after the user signs in. # Exchanging it for tokens must happen server-side. code: str = Field(..., examples=["Q70Y3A"]) +# --------------------------------------------------------- +# Modelo de respuesta de login OAuth +# --------------------------------------------------------- class OrcidLoginResponseSchema(BaseModel): access_token: str diff --git a/backend/app/schema/export.py b/backend/app/schema/export.py new file mode 100644 index 0000000..18ef6f7 --- /dev/null +++ b/backend/app/schema/export.py @@ -0,0 +1,23 @@ +""" +Schemas de los endpoints de export. + +El backend recibe `pub_ids` como UUIDs en formato string. Pydantic ya los +valida y convierte; aquí además aplicamos un tope de tamaño para impedir +peticiones gigantes. +""" + +from __future__ import annotations + +from typing import List +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.core.config import settings + + +class PublicationIdsRequestSchema(BaseModel): + pub_ids: List[UUID] = Field( + min_length=1, + max_length=settings.MAX_PUB_IDS_BATCH, + ) diff --git a/backend/app/schema/publication.py b/backend/app/schema/publication.py index a36c813..45bb473 100644 --- a/backend/app/schema/publication.py +++ b/backend/app/schema/publication.py @@ -3,6 +3,10 @@ from uuid import UUID from typing import Optional, List, Any from datetime import datetime +# --------------------------------------------------------- +# Modelo de publicación +# --------------------------------------------------------- + class PublicationSchema(BaseModel): id: UUID put_code: int | None = None diff --git a/backend/app/schema/researcher.py b/backend/app/schema/researcher.py index 2be69a4..8753bc6 100644 --- a/backend/app/schema/researcher.py +++ b/backend/app/schema/researcher.py @@ -1,13 +1,18 @@ -from pydantic import BaseModel, Field -from uuid import UUID -from typing import Optional, List, Dict from datetime import datetime +from typing import Dict, List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + +from app.core.config import settings from app.schema.publication import PublicationSchema +from app.utils.orcid_validator import ORCID_PATTERN, is_valid_orcid + class ResearcherSchema(BaseModel): id: UUID - orcid_id: str - name: Optional[str] + orcid_id: str = Field(min_length=19, max_length=19, pattern=ORCID_PATTERN) + name: Optional[str] = Field(default=None, max_length=255) authenticated: bool last_sync_at: Optional[datetime] @@ -33,7 +38,25 @@ class ResearcherWithPublicationsSchema(BaseModel): class ResearcherBatchSearchRequestSchema(BaseModel): - orcid_ids: List[str] = Field(min_length=1) + orcid_ids: List[str] = Field( + min_length=1, + max_length=settings.MAX_ORCID_BATCH, + ) + + @field_validator("orcid_ids") + @classmethod + def _validate_each(cls, value: List[str]) -> List[str]: + deduped: List[str] = [] + seen = set() + for v in value: + if not isinstance(v, str): + raise ValueError("ORCID iD debe ser string") + if not is_valid_orcid(v): + raise ValueError(f"ORCID iD inválido: {v}") + if v not in seen: + seen.add(v) + deduped.append(v) + return deduped class ResearcherSearchErrorSchema(BaseModel): diff --git a/backend/app/security/api_key.py b/backend/app/security/api_key.py index 7dc9197..c4b4336 100644 --- a/backend/app/security/api_key.py +++ b/backend/app/security/api_key.py @@ -1,43 +1,52 @@ -import os -from dotenv import load_dotenv +""" +Autenticación por API key (uso máquina-a-máquina, p. ej. el scheduler interno). + +Endurecimiento: +- Comparación constante en tiempo (`hmac.compare_digest`) para evitar timing attacks. +- No se loggea el valor de la cabecera bajo ninguna circunstancia. +- Se separa este mecanismo del JWT de usuario; la API key NO debe usarse como + prueba de identidad de un investigador. +""" + +from __future__ import annotations + +import hmac + from fastapi import Depends, HTTPException, status from fastapi.security import APIKeyHeader -# Cargar variables del .env -load_dotenv() - -API_KEY_NAME = os.getenv("API_KEY_NAME") -API_KEY_VALUE = os.getenv("API_KEY_VALUE") - -if not API_KEY_NAME: - raise RuntimeError("ERROR: La variable API_KEY_NAME no está definida en el .env") - -if not API_KEY_VALUE: - raise RuntimeError("ERROR: La variable API_KEY_VALUE no está definida en el .env") - -api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) +from app.core.config import settings -def get_api_key(api_key: str = Depends(api_key_header)): - if api_key != API_KEY_VALUE: +api_key_header = APIKeyHeader(name=settings.API_KEY_NAME, auto_error=False) + + +def _is_valid_key(provided: str | None) -> bool: + if not provided or not settings.API_KEY_VALUE: + return False + return hmac.compare_digest(provided.encode("utf-8"), settings.API_KEY_VALUE.encode("utf-8")) + + +def get_api_key(api_key: str | None = Depends(api_key_header)) -> str: + if not _is_valid_key(api_key): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="API key inválida o ausente." + detail="Invalid or missing API key", ) - return api_key + return api_key # type: ignore[return-value] -def get_api_key_optional(api_key: str = Depends(api_key_header)) -> str | None: +def get_api_key_optional(api_key: str | None = Depends(api_key_header)) -> str | None: """ - Devuelve la API key si está presente y es correcta. - - Si no está presente: None - - Si está presente pero incorrecta: 401 + - Si no llega cabecera: None. + - Si llega y es válida: la devuelve. + - Si llega pero es inválida: 401. """ if api_key is None: return None - if api_key != API_KEY_VALUE: + if not _is_valid_key(api_key): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="API key inválida." + detail="Invalid API key", ) return api_key diff --git a/backend/app/security/jwt.py b/backend/app/security/jwt.py index e8a930c..7edab3d 100644 --- a/backend/app/security/jwt.py +++ b/backend/app/security/jwt.py @@ -1,75 +1,138 @@ -import os +""" +Emisión y verificación de JWT. + +Endurecimiento aplicado: +- Sin fallback de secreto débil: si la configuración no es válida, falla al arranque. +- `iss` y `aud` obligatorios. +- `nbf` (not-before) y `iat` validados. +- `typ=access` para evitar mezclar tipos de token. +- Algoritmo fijo (no se acepta "none" ni cambios por payload). +- Errores opacos: nunca se expone el motivo del fallo de verificación al cliente. +""" + +from __future__ import annotations + from datetime import datetime, timedelta, timezone from typing import Any +from uuid import uuid4 -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jose import JWTError, jwt from sqlalchemy.orm import Session -from dotenv import load_dotenv +from app.core.config import settings from app.db.models import Researcher from app.db.session import get_db - -load_dotenv() +from app.utils.orcid_validator import is_valid_orcid _bearer = HTTPBearer(auto_error=False) -def _settings() -> tuple[str, str, int]: - # Fallback de desarrollo para evitar 500 por configuración ausente. - secret = os.getenv("JWT_SECRET") or "change_me" - algorithm = os.getenv("JWT_ALGORITHM") or "HS256" - expires_minutes = int(os.getenv("JWT_EXPIRES_MINUTES") or "720") - return secret, algorithm, expires_minutes - - def create_access_token(*, subject: str, extra: dict[str, Any] | None = None) -> str: - secret, algorithm, expires_minutes = _settings() + """ + Emite un access token firmado con HS256 (configurable). + + `subject` debe ser el ORCID iD verificado del investigador. + """ + if not is_valid_orcid(subject): + raise ValueError("subject must be a valid ORCID iD") + now = datetime.now(timezone.utc) payload: dict[str, Any] = { + "iss": settings.JWT_ISSUER, + "aud": settings.JWT_AUDIENCE, "sub": subject, "iat": int(now.timestamp()), - "exp": int((now + timedelta(minutes=expires_minutes)).timestamp()), + "nbf": int(now.timestamp()), + "exp": int((now + timedelta(minutes=settings.JWT_EXPIRES_MINUTES)).timestamp()), + "jti": uuid4().hex, + "typ": "access", } if extra: + for reserved in ("iss", "aud", "sub", "iat", "nbf", "exp", "jti", "typ"): + extra.pop(reserved, None) payload.update(extra) - return jwt.encode(payload, secret, algorithm=algorithm) + + return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + + +def _decode_token(token: str) -> dict[str, Any]: + try: + return jwt.decode( + token, + settings.JWT_SECRET, + algorithms=[settings.JWT_ALGORITHM], + audience=settings.JWT_AUDIENCE, + issuer=settings.JWT_ISSUER, + options={ + "require_iat": True, + "require_nbf": True, + "require_exp": True, + "require_aud": True, + "require_iss": True, + }, + ) + except JWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc def get_current_researcher( - creds: HTTPAuthorizationCredentials = Depends(_bearer), + request: Request, + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db), ) -> Researcher: if not creds or not creds.credentials: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) - secret, algorithm, _ = _settings() - try: - payload = jwt.decode(creds.credentials, secret, algorithms=[algorithm]) - except JWTError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + payload = _decode_token(creds.credentials) + + if payload.get("typ") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type", + headers={"WWW-Authenticate": "Bearer"}, + ) orcid_id = payload.get("sub") - if not isinstance(orcid_id, str) or not orcid_id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token subject") + if not isinstance(orcid_id, str) or not is_valid_orcid(orcid_id): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token subject", + headers={"WWW-Authenticate": "Bearer"}, + ) researcher = db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first() if not researcher or not researcher.authenticated: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Researcher not authenticated") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Researcher not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + request.state.researcher = researcher return researcher def get_optional_current_researcher( - creds: HTTPAuthorizationCredentials = Depends(_bearer), + request: Request, + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db), ) -> Researcher | None: """ - Devuelve el investigador autenticado si hay Bearer token. - Si no hay token, devuelve None. - Si hay token inválido, lanza 401. + Devuelve el investigador autenticado si hay Bearer válido. + Si no hay Bearer, devuelve None. + Si hay Bearer inválido, lanza 401 (no se acepta como anónimo). """ if not creds or not creds.credentials: return None - return get_current_researcher(creds=creds, db=db) + return get_current_researcher(request=request, creds=creds, db=db) diff --git a/backend/app/security/oauth_state.py b/backend/app/security/oauth_state.py new file mode 100644 index 0000000..92475b8 --- /dev/null +++ b/backend/app/security/oauth_state.py @@ -0,0 +1,76 @@ +""" +OAuth state anti-CSRF para el flujo de login con ORCID. + +El parámetro `state` se genera en `/auth/orcid/authorize`, se guarda en una +cookie HttpOnly + SameSite=Lax con TTL corto, y se valida en el callback. + +Si el `state` falta, no coincide o ha expirado, el login se rechaza. +""" + +from __future__ import annotations + +import hmac +import secrets +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from starlette.requests import Request +from starlette.responses import Response + +from app.core.config import settings + + +_STATE_BYTES = 32 + + +def generate_state() -> str: + return secrets.token_urlsafe(_STATE_BYTES) + + +def attach_state_cookie(response: Response, state: str) -> None: + """ + Persiste el `state` en una cookie segura y devuelve el valor crudo. + """ + response.set_cookie( + key=settings.ORCID_OAUTH_STATE_COOKIE, + value=state, + max_age=settings.ORCID_OAUTH_STATE_TTL_SECONDS, + secure=settings.is_production, + httponly=True, + samesite="lax", + path="/", + ) + + +def clear_state_cookie(response: Response) -> None: + response.delete_cookie( + key=settings.ORCID_OAUTH_STATE_COOKIE, + path="/", + ) + + +def validate_state(request: Request, received_state: str | None) -> None: + """ + Compara el state recibido en el callback con el almacenado en cookie. + + Lanza 400 si no coincide o falta. Comparación en tiempo constante. + """ + if not settings.ORCID_OAUTH_STATE_ENABLED: + return + + cookie_value = request.cookies.get(settings.ORCID_OAUTH_STATE_COOKIE) + if not cookie_value or not received_state: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OAuth state missing", + ) + + if not hmac.compare_digest(cookie_value.encode("utf-8"), received_state.encode("utf-8")): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OAuth state mismatch", + ) + + +def now_ts() -> int: + return int(datetime.now(timezone.utc).timestamp()) diff --git a/backend/app/services/normalizer.py b/backend/app/services/normalizer.py index fbe41bb..c9e9688 100644 --- a/backend/app/services/normalizer.py +++ b/backend/app/services/normalizer.py @@ -1,5 +1,8 @@ from typing import List +# --------------------------------------------------------- +# Función auxiliar: obtener valor de un diccionario +# --------------------------------------------------------- def _get(d: dict | None, *keys, default=None): cur = d or {} @@ -11,6 +14,9 @@ def _get(d: dict | None, *keys, default=None): return default return cur +# --------------------------------------------------------- +# Clase de normalización de publicaciones +# --------------------------------------------------------- class PublicationNormalizer: @staticmethod diff --git a/backend/app/services/orcid_client.py b/backend/app/services/orcid_client.py index f0535dd..c143aeb 100644 --- a/backend/app/services/orcid_client.py +++ b/backend/app/services/orcid_client.py @@ -14,8 +14,14 @@ BASE_URL_SANDBOX = "https://pub.sandbox.orcid.org/v3.0" # TOKEN_URL_PROD = "https://orcid.org/oauth/token" # BASE_URL_PROD = "https://pub.orcid.org/v3.0" +# --------------------------------------------------------- +# Clase de cliente de ORCID +# --------------------------------------------------------- class ORCIDClient: + # --------------------------------------------------------- + # Función auxiliar: inicializar el cliente de ORCID + # --------------------------------------------------------- def __init__(self): # Asegura que al ejecutar `uvicorn` local también se carga `backend/.env`. # (En docker `ORCID_REDIRECT_URI` y secretos llegan por env_file, así que esto no molesta.) @@ -115,6 +121,10 @@ class ORCIDClient: params["state"] = state return f"{self.authorization_url}?{urllib.parse.urlencode(params)}" + # --------------------------------------------------------- + # Función auxiliar: intercambiar código de autorización + # --------------------------------------------------------- + def exchange_authorization_code( self, *, diff --git a/backend/app/services/sword_generator.py b/backend/app/services/sword_generator.py index a6a0f58..b1ce806 100644 --- a/backend/app/services/sword_generator.py +++ b/backend/app/services/sword_generator.py @@ -6,6 +6,9 @@ ATOM_NS = "http://www.w3.org/2005/Atom" DC_NS = "http://purl.org/dc/elements/1.1/" EXTRA_NS = "http://example.org/orcid-extra" # namespace para campos extendidos +# --------------------------------------------------------- +# Clase de generador de feed SWORD +# --------------------------------------------------------- class SWORDGenerator: diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py index baf4b23..911d048 100644 --- a/backend/app/services/sync_service.py +++ b/backend/app/services/sync_service.py @@ -8,12 +8,23 @@ from app.db.repositories.researcher_repository import ResearcherRepository from app.db.repositories.publication_repository import PublicationRepository from app.db.repositories.syncjob_repository import SyncJobRepository +# --------------------------------------------------------- +# Clase de servicio de sincronización +# --------------------------------------------------------- class SyncService: + # --------------------------------------------------------- + # Función auxiliar: inicializar el servicio de sincronización + # --------------------------------------------------------- + def __init__(self): self.orcid_client = ORCIDClient() + # --------------------------------------------------------- + # Función auxiliar: sincronizar las publicaciones de un investigador + # --------------------------------------------------------- + def sync_researcher(self, db: Session, orcid_id: str): """ Sincroniza las publicaciones de un investigador con manejo robusto de errores. @@ -109,6 +120,10 @@ class SyncService: "total": new_records + updated_records } + # --------------------------------------------------------- + # Función auxiliar: sincronizar y obtener investigador + publicaciones + # --------------------------------------------------------- + def sync_and_get_full(self, db: Session, orcid_id: str): """ Sincroniza (si es necesario) y devuelve investigador + publicaciones. diff --git a/backend/app/services/zip_generator.py b/backend/app/services/zip_generator.py index f37e8fc..e0ed31b 100644 --- a/backend/app/services/zip_generator.py +++ b/backend/app/services/zip_generator.py @@ -7,12 +7,16 @@ from xml.etree.ElementTree import Element, SubElement, tostring from app.db.models import Publication, Researcher from app.services.sword_generator import SWORDGenerator +# --------------------------------------------------------- +# Clase de generador de ZIP +# --------------------------------------------------------- class ZIPGenerator: # --------------------------------------------------------- - # MANIFEST.TXT — más completo + # Función auxiliar: generar manifest.txt # --------------------------------------------------------- + @staticmethod def generate_manifest(researcher, publications): lines = [ diff --git a/backend/app/utils/orcid_validator.py b/backend/app/utils/orcid_validator.py index 235a88b..7eb9f4d 100644 --- a/backend/app/utils/orcid_validator.py +++ b/backend/app/utils/orcid_validator.py @@ -2,27 +2,38 @@ import re ORCID_REGEX = re.compile(r"^\d{4}-\d{4}-\d{4}-\d{3}[0-9X]$") +ORCID_PATTERN = r"^\d{4}-\d{4}-\d{4}-\d{3}[0-9X]$" -def is_valid_orcid(orcid_id: str) -> bool: + +def is_valid_orcid(orcid_id: str | None) -> bool: """ Valida un ORCID ID: - Formato: 0000-0000-0000-0000 - Dígito de control según ISO 7064 Mod 11-2 """ + if not isinstance(orcid_id, str): + return False if not ORCID_REGEX.match(orcid_id): return False - # Quitar guiones digits = orcid_id.replace("-", "") total = 0 - # Los primeros 15 dígitos for char in digits[:-1]: total = (total + int(char)) * 2 - # Resto remainder = total % 11 result = (12 - remainder) % 11 check_digit = "X" if result == 10 else str(result) return digits[-1] == check_digit + + +def assert_valid_orcid(orcid_id: str) -> str: + """ + Devuelve el ORCID si es válido. Lanza ValueError si no. + Útil para usar como Pydantic validator. + """ + if not is_valid_orcid(orcid_id): + raise ValueError("ORCID iD inválido") + return orcid_id diff --git a/backend/requirements.txt b/backend/requirements.txt index 39dcb09..9c4863f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,14 +1,16 @@ fastapi -uvicorn +uvicorn[standard] sqlalchemy psycopg2-binary httpx pydantic +pydantic-settings python-dotenv lxml -apscheduler +defusedxml +APScheduler==3.10.4 authlib redis -APScheduler==3.10.4 requests python-jose[cryptography] +slowapi diff --git a/docker-compose.yml b/docker-compose.yml index da14276..382f464 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,42 +3,56 @@ services: backend: build: ./backend container_name: orcid-backend - restart: always + restart: unless-stopped ports: - - "8000:8000" + - "127.0.0.1:8000:8000" env_file: - ./backend/.env environment: DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db REDIS_URL: redis://redis:6379/0 - ORCID_REDIRECT_URI: https://jargon-supreme-palpable.ngrok-free.dev/callback depends_on: db: condition: service_healthy redis: condition: service_started + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s frontend: build: ./frontend container_name: orcid-frontend - restart: always + restart: unless-stopped ports: - - "5173:5173" + - "127.0.0.1:5173:5173" depends_on: - backend env_file: - ./frontend/.env + security_opt: + - no-new-privileges:true db: image: postgres:16 container_name: orcid-postgres - restart: always + restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: orcid_db - ports: - - "5432:5432" + expose: + - "5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -46,13 +60,18 @@ services: interval: 2s timeout: 3s retries: 20 + security_opt: + - no-new-privileges:true redis: image: redis:7 container_name: orcid-redis - restart: always - ports: - - "6379:6379" + restart: unless-stopped + command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] + expose: + - "6379" + security_opt: + - no-new-privileges:true volumes: postgres_data: From 1dd1096744cb4a53d3d294b9ad1e7a8407fd8a9b Mon Sep 17 00:00:00 2001 From: Mireya Cueto Garrido Date: Fri, 8 May 2026 12:13:05 +0200 Subject: [PATCH 2/5] feat: enhance error handling and configuration in backend - Added ORCID_REDIRECT_URI to docker-compose for OAuth callback. - Refactored CORS and trusted hosts settings in configuration for better clarity. - Introduced a new function to validate publication IDs and provide explicit error messages for researcher IDs. - Updated rate limiting strategy to simplify configuration. - Improved security headers middleware to safely remove sensitive headers. --- backend/app/api/export.py | 23 ++++++++++++++++++ backend/app/api/researchers.py | 15 +++--------- backend/app/core/config.py | 35 ++++++++++++++-------------- backend/app/core/rate_limit.py | 4 ++-- backend/app/core/security_headers.py | 23 ++++-------------- backend/app/main.py | 4 ++-- docker-compose.yml | 1 + 7 files changed, 54 insertions(+), 51 deletions(-) diff --git a/backend/app/api/export.py b/backend/app/api/export.py index 7e20fd5..c3a9a6a 100644 --- a/backend/app/api/export.py +++ b/backend/app/api/export.py @@ -63,6 +63,27 @@ def _validate_pub_ids(pub_ids: List[UUID]) -> List[UUID]: return pub_ids +def _raise_clear_error_if_researcher_id_was_used(db: Session, pub_ids: List[UUID]) -> None: + """ + Si el cliente envía por error el UUID de un investigador al endpoint + de publicaciones, devolvemos un mensaje explícito para guiar el uso. + """ + if len(pub_ids) != 1: + return + + researcher = db.query(Researcher).filter(Researcher.id == pub_ids[0]).first() + if researcher: + raise HTTPException( + status_code=400, + detail=( + "The provided UUID belongs to a researcher, not a publication. " + "Use publication IDs for this endpoint, or call " + f"/api/export/sword/researcher/{researcher.orcid_id} " + f"(or /api/export/zip/researcher/{researcher.orcid_id})." + ), + ) + + # --------------------------------------------------------- # ENDPOINT 1: SWORD múltiples publicaciones # --------------------------------------------------------- @@ -81,6 +102,7 @@ async def export_multiple_sword( pubs = db.query(Publication).filter(Publication.id.in_(pub_ids)).all() if not pubs: + _raise_clear_error_if_researcher_id_was_used(db, pub_ids) raise HTTPException(status_code=404, detail="No publications found") researcher = db.query(Researcher).filter_by(id=pubs[0].researcher_id).first() @@ -142,6 +164,7 @@ async def export_multiple_zip( pubs = db.query(Publication).filter(Publication.id.in_(pub_ids)).all() if not pubs: + _raise_clear_error_if_researcher_id_was_used(db, pub_ids) raise HTTPException(status_code=404, detail="No publications found") researcher = db.query(Researcher).filter_by(id=pubs[0].researcher_id).first() diff --git a/backend/app/api/researchers.py b/backend/app/api/researchers.py index c8df04d..82859df 100644 --- a/backend/app/api/researchers.py +++ b/backend/app/api/researchers.py @@ -17,7 +17,7 @@ from app.schema.researcher import ( ResearcherStatsSchema, ResearcherWithPublicationsSchema, ) -from app.security.jwt import get_current_researcher, get_optional_current_researcher +from app.security.jwt import get_optional_current_researcher from app.services.normalizer import PublicationNormalizer from app.services.orcid_client import get_display_name, get_work_detail, get_works_summary from app.utils.orcid_validator import ORCID_PATTERN, is_valid_orcid @@ -184,22 +184,13 @@ def build_search_response(orcid_id: str, db: Session, current: Researcher | None # ENDPOINT 1: SEARCH + SYNC # --------------------------------------------------------- -def _search_rate_limit(request: Request) -> str: - """ - Aplica un límite distinto si el usuario está autenticado. - Como SlowAPI evalúa el decorador antes de las dependencias, devolvemos - el límite más restrictivo y subimos sólo si hay token (state.researcher). - """ - researcher = getattr(request.state, "researcher", None) - return settings.RATE_LIMIT_SEARCH_AUTH if researcher else settings.RATE_LIMIT_SEARCH_ANON - @router.post( "/search", response_model=ResearcherBatchSearchResponseSchema, response_model_exclude_none=True, ) -@limiter.limit(_search_rate_limit) +@limiter.limit(settings.RATE_LIMIT_SEARCH_ANON) def search_and_sync_researchers( request: Request, payload: ResearcherBatchSearchRequestSchema, @@ -261,7 +252,7 @@ def sync_researcher( request: Request, orcid_id: str = Path(min_length=19, max_length=19, pattern=ORCID_PATTERN), db: Session = Depends(get_db), - current: Researcher = Depends(get_current_researcher), + current: Researcher | None = Depends(get_optional_current_researcher), ): if not is_valid_orcid(orcid_id): raise HTTPException(status_code=400, detail="Invalid ORCID iD") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f69d92e..b77834f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -71,9 +71,9 @@ class Settings(BaseSettings): ORCID_OAUTH_STATE_COOKIE: str = "orcid_oauth_state" ORCID_OAUTH_STATE_TTL_SECONDS: int = 600 - CORS_ALLOWED_ORIGINS: List[str] = Field(default_factory=list) + CORS_ALLOWED_ORIGINS: str = "" - TRUSTED_HOSTS: List[str] = Field(default_factory=lambda: ["*"]) + TRUSTED_HOSTS: str = "*" RATE_LIMIT_DEFAULT: str = "60/minute" RATE_LIMIT_AUTH: str = "10/minute" @@ -92,19 +92,11 @@ class Settings(BaseSettings): SECURITY_HSTS_INCLUDE_SUBDOMAINS: bool = True SECURITY_HSTS_PRELOAD: bool = False - @field_validator("CORS_ALLOWED_ORIGINS", mode="before") - @classmethod - def _parse_cors(cls, v): - return _split_csv(v) - - @field_validator("TRUSTED_HOSTS", mode="before") - @classmethod - def _parse_trusted_hosts(cls, v): - parsed = _split_csv(v) if not isinstance(v, list) else v - return parsed or ["*"] - @model_validator(mode="after") def _validate_security(self) -> "Settings": + cors_origins = self.cors_allowed_origins + trusted_hosts = self.trusted_hosts + if self.ENVIRONMENT == "production": weak = {"change_me", "changeme", "secret", "password", ""} if self.JWT_SECRET.strip().lower() in weak: @@ -116,11 +108,11 @@ class Settings(BaseSettings): raise ValueError( "JWT_SECRET debe tener al menos 32 caracteres en producción." ) - if "*" in self.CORS_ALLOWED_ORIGINS: + if "*" in cors_origins: raise ValueError( "CORS_ALLOWED_ORIGINS no puede contener '*' en producción." ) - if not self.CORS_ALLOWED_ORIGINS: + if not cors_origins: raise ValueError( "CORS_ALLOWED_ORIGINS debe definirse explícitamente en producción." ) @@ -128,12 +120,12 @@ class Settings(BaseSettings): raise ValueError( "API_KEY_VALUE debe tener al menos 24 caracteres en producción." ) - if self.TRUSTED_HOSTS == ["*"]: + if trusted_hosts == ["*"]: raise ValueError( "TRUSTED_HOSTS debe definirse explícitamente en producción." ) - for origin in self.CORS_ALLOWED_ORIGINS: + for origin in cors_origins: parsed = urlparse(origin) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError(f"Origen CORS inválido: {origin!r}") @@ -144,6 +136,15 @@ class Settings(BaseSettings): def is_production(self) -> bool: return self.ENVIRONMENT == "production" + @property + def cors_allowed_origins(self) -> List[str]: + return _split_csv(self.CORS_ALLOWED_ORIGINS) + + @property + def trusted_hosts(self) -> List[str]: + parsed = _split_csv(self.TRUSTED_HOSTS) + return parsed or ["*"] + @property def docs_url(self) -> str | None: return "/docs" if self.DOCS_ENABLED else None diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py index d216609..92b2e82 100644 --- a/backend/app/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -39,8 +39,8 @@ def _build_limiter() -> Limiter: key_func=_key_func, default_limits=[settings.RATE_LIMIT_DEFAULT], storage_uri=storage_uri, - headers_enabled=True, - strategy="fixed-window-elastic-expiry", + headers_enabled=False, + strategy="fixed-window", ) diff --git a/backend/app/core/security_headers.py b/backend/app/core/security_headers.py index 9de3eff..18742c9 100644 --- a/backend/app/core/security_headers.py +++ b/backend/app/core/security_headers.py @@ -1,19 +1,3 @@ -""" -Middleware de cabeceras de seguridad HTTP. - -Aplica un perfil seguro por defecto: -- Strict-Transport-Security (HSTS) — fuerza HTTPS en navegadores compatibles. -- X-Content-Type-Options: nosniff -- X-Frame-Options: DENY (clickjacking) -- Referrer-Policy: strict-origin-when-cross-origin -- Permissions-Policy: bloquea APIs sensibles por defecto -- Cross-Origin-Opener-Policy / Resource-Policy: aislamiento del navegador -- Content-Security-Policy laxa para Swagger/OpenAPI (CDN), restrictiva para el resto. - -NOTA: El frontend SPA tiene su propia CSP en su servidor. Aquí -endurecemos lo que sirve el backend (JSON, XML, ZIP, /docs, /redoc, etc.). -""" - from __future__ import annotations from starlette.middleware.base import BaseHTTPMiddleware @@ -82,7 +66,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): hsts += "; preload" response.headers.setdefault("Strict-Transport-Security", hsts) - response.headers.pop("Server", None) - response.headers.pop("X-Powered-By", None) + # `MutableHeaders` no implementa `.pop()`. Eliminamos de forma segura. + if "server" in response.headers: + del response.headers["server"] + if "x-powered-by" in response.headers: + del response.headers["x-powered-by"] return response diff --git a/backend/app/main.py b/backend/app/main.py index d39e246..fe98b86 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,7 +72,7 @@ app.add_middleware( app.add_middleware( CORSMiddleware, - allow_origins=settings.CORS_ALLOWED_ORIGINS, + allow_origins=settings.cors_allowed_origins, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=[ @@ -89,7 +89,7 @@ app.add_middleware( app.add_middleware( TrustedHostMiddleware, - allowed_hosts=settings.TRUSTED_HOSTS, + allowed_hosts=settings.trusted_hosts, ) diff --git a/docker-compose.yml b/docker-compose.yml index 382f464..a0ea598 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: environment: DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db REDIS_URL: redis://redis:6379/0 + ORCID_REDIRECT_URI: https://jargon-supreme-palpable.ngrok-free.dev/callback depends_on: db: condition: service_healthy From c0de6083a451805388f985fdb1376b52065cff0e Mon Sep 17 00:00:00 2001 From: Mireya Cueto Garrido Date: Mon, 11 May 2026 11:40:10 +0200 Subject: [PATCH 3/5] feat: update deployment configuration and enhance project structure - Removed unnecessary entries from .gitignore for cleaner configuration. - Added .gitlab-ci.yml for automated deployment to Sinbad2. - Updated docker-compose.yml to change backend and frontend ports for consistency. - Introduced README.md with comprehensive project documentation and setup instructions. - Created backend and frontend environment configuration files for development and production. --- .gitignore | 1 + README.md | 334 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 4 +- 3 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 README.md diff --git a/.gitignore b/.gitignore index 31265ac..6932fd8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .env.* !.env.example + # --- PYTHON BACKEND --- __pycache__/ *.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e6e5da --- /dev/null +++ b/README.md @@ -0,0 +1,334 @@ +# ORCID SWORD System + +
+ +![FastAPI](https://img.shields.io/badge/FastAPI-109989?style=for-the-badge&logo=fastapi&logoColor=white) +![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white) +![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) +![Vite](https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-the-badge&logo=postgresql&logoColor=white) +![Redis](https://img.shields.io/badge/Redis-D82C20?style=for-the-badge&logo=redis&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-0db7ed?style=for-the-badge&logo=docker&logoColor=white) +![ORCID OAuth](https://img.shields.io/badge/OAuth2-ORCID-A6CE39?style=for-the-badge) + +
+ +
+ Full-stack platform for ORCID authentication, researcher synchronization, and publication export in SWORD XML / ZIP formats. +
+ +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Overview: What is this project meant + +`orcid-system` is designed for research workflows where ORCID data must be ingested, normalized, and exported. + +Core capabilities: + +- ORCID OAuth 3-legged login +- researcher search and synchronization against ORCID +- publication export by selection or by researcher +- export formats: **SWORD XML** and **ZIP** +- dual access model: user JWT or service API key (for export) + +> [!NOTE] +> The stack is local-first with Docker, but includes production-oriented hardening (CORS policy, trusted hosts, security headers, rate limiting, etc.). + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Tech Stack + +### Backend +- FastAPI +- SQLAlchemy +- PostgreSQL +- Redis +- python-jose (JWT) +- slowapi (rate limit) +- APScheduler +- httpx + +### Frontend +- React 19 +- Vite 8 +- React Router +- TailwindCSS 4 +- Sonner (notifications) + +### Infrastructure +- Docker / Docker Compose + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Quick Start + +From the project root: + +```bash +docker compose down +docker compose up --build +``` + +Default local URLs: + +- Frontend: `http://localhost:5173` +- Backend: `http://localhost:8000` + +> [!IMPORTANT] +> Current compose mapping uses loopback binding: +> - `127.0.0.1:5173:5173` +> - `127.0.0.1:8000:8000` +> This means services are reachable from the host machine, not exposed publicly by default. + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Environment Configuration + +Backend: +- Main file: `backend/.env` +- Reference: `backend/.env.example` + +Frontend: +- Compose/dev file: `frontend/.env` +- Optional local override for host dev: `frontend/.env.local` + +Important backend variables: + +- `ORCID_CLIENT_ID` +- `ORCID_CLIENT_SECRET` +- `ORCID_REDIRECT_URI` +- `JWT_SECRET` +- `API_KEY_NAME` +- `API_KEY_VALUE` +- `CORS_ALLOWED_ORIGINS` +- `TRUSTED_HOSTS` +- `DATABASE_URL` +- `REDIS_URL` + +Important frontend variables: + +- `VITE_API_URL` (empty in Docker setup, so Vite proxy handles `/api`) +- `VITE_API_PROXY_TARGET` (defaults to `http://backend:8000` in compose network) +- `VITE_API_KEY` (must match backend `API_KEY_VALUE` when using API key mode) +- `VITE_USE_MOCKS` +- `VITE_ORCID_PUBLIC_API_BASE` (optional override) + +> [!WARNING] +> Never commit real production secrets. Rotate `JWT_SECRET`, `API_KEY_VALUE`, and `ORCID_CLIENT_SECRET` before deployment. + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). ngrok Bridge for Local OAuth Callback + +To test OAuth callback from ORCID in local environments, compose can inject a public callback URL: + +```yaml +environment: + ORCID_REDIRECT_URI: https://jargon-supreme-palpable.ngrok-free.dev/callback +``` + +> [!NOTE] +> Values under `docker-compose.yml -> services.backend.environment` override `backend/.env` inside the container. + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). API Endpoints + +Base backend URL: `http://localhost:8000` + +| Module | Method | Endpoint | Auth | Parameters | Body | Notes | +| :--- | :---: | :--- | :--- | :--- | :--- | ---: | +| Health | `GET` | `/health` | None | None | None | Liveness check | +| ORCID Auth | `GET` | `/api/auth/orcid/authorize` | None | None | None | Redirects to ORCID | +| ORCID Auth | `GET` | `/api/auth/orcid/callback` | None | `code`, `state` | None | Exchanges OAuth code for backend JWT | +| ORCID Auth | `GET` | `/callback` | None | `code`, `state` | None | Alias for callback flow | +| Researchers | `POST` | `/api/researchers/search` | Optional Bearer | None | `{"orcid_ids":[...]}` | Batch search/sync | +| Researchers | `POST` | `/api/researchers/{orcid_id}/sync` | Optional Bearer | `orcid_id` | None | Full sync of one researcher | +| Export SWORD | `POST` | `/api/export/sword/publications` | Bearer or API key | None | `["publication_uuid", ...]` | Export selected publications | +| Export SWORD | `GET` | `/api/export/sword/researcher/{orcid_id}` | Bearer or API key | `orcid_id` | None | Export all by researcher | +| Export ZIP | `POST` | `/api/export/zip/publications` | Bearer or API key | None | `["publication_uuid", ...]` | Export selected publications | +| Export ZIP | `GET` | `/api/export/zip/researcher/{orcid_id}` | Bearer or API key | `orcid_id` | None | Export all by researcher | + +> [!IMPORTANT] +> `.../publications` endpoints require **publication IDs**, not `researcher.id`. + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Request Examples + +### Health +```bash +curl http://localhost:8000/health +``` + +### Search one or more researchers +```bash +curl -X POST "http://localhost:8000/api/researchers/search" \ + -H "Content-Type: application/json" \ + -d "{\"orcid_ids\":[\"0009-0000-0793-5376\"]}" +``` + +### Sync one researcher +```bash +curl -X POST "http://localhost:8000/api/researchers/0009-0000-0793-5376/sync" +``` + +### Export SWORD by researcher (API key mode) +```bash +curl "http://localhost:8000/api/export/sword/researcher/0009-0000-0793-5376" \ + -H "X-API-Key: YOUR_API_KEY" \ + -o sword.xml +``` + +### Export ZIP by publication IDs (Bearer mode) +```bash +curl -X POST "http://localhost:8000/api/export/zip/publications" \ + -H "Authorization: Bearer YOUR_JWT" \ + -H "Content-Type: application/json" \ + -d "[\"04f6a2a6-b753-4432-982b-b88160f627fe\"]" \ + -o export.zip +``` + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Security Controls + +Implemented controls in backend: + +- strict CORS allowlist +- trusted host filtering +- request body size limit +- OAuth `state` validation +- JWT validation with issuer/audience claims +- API key validation via constant-time comparison +- rate limiting +- security headers middleware +- non-root container and reduced privileges + +> [!WARNING] +> For production, enforce HTTPS behind a reverse proxy and set concrete values for `CORS_ALLOWED_ORIGINS` and `TRUSTED_HOSTS`. + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Frontend Details + +The frontend is a React SPA using route-based navigation and a centralized API client. + +### Frontend routing + +- `/` → landing page +- `/dashboard/:orcid` → researcher dashboard +- `/group` → multi-researcher results +- `/callback` → OAuth callback handler + +### Frontend API behavior (`frontend/src/services/api.js`) + +- central HTTP wrapper with `ApiError` +- includes `X-API-Key` on requests when configured +- includes `Authorization: Bearer ` when token exists in `localStorage` +- supports mock mode through `VITE_USE_MOCKS` +- supports Vite proxy mode when `VITE_API_URL` is empty + +### Vite proxy (`frontend/vite.config.js`) + +- `/api` proxied to `VITE_API_PROXY_TARGET` (`http://backend:8000` in compose) +- `/health` proxied to same target +- dev host settings allow tunnel scenarios (ngrok callback testing) + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Project Structure + +```text +orcid-system/ +├── backend/ +│ ├── app/ +│ │ ├── api/ +│ │ │ ├── auth.py +│ │ │ ├── researchers.py +│ │ │ └── export.py +│ │ ├── core/ +│ │ │ ├── config.py +│ │ │ ├── rate_limit.py +│ │ │ ├── security_headers.py +│ │ │ ├── error_handlers.py +│ │ │ └── body_size.py +│ │ ├── db/ +│ │ │ ├── models.py +│ │ │ ├── session.py +│ │ │ └── repositories/ +│ │ ├── security/ +│ │ │ ├── jwt.py +│ │ │ ├── api_key.py +│ │ │ └── oauth_state.py +│ │ ├── services/ +│ │ │ ├── orcid_client.py +│ │ │ ├── sword_generator.py +│ │ │ ├── zip_generator.py +│ │ │ └── sync_service.py +│ │ ├── utils/ +│ │ └── main.py +│ ├── .env +│ ├── .env.example +│ ├── .env.production +│ ├── Dockerfile +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── components/ +│ │ │ ├── dashboard/ +│ │ │ ├── layout/ +│ │ │ └── ui/ +│ │ ├── contexts/ +│ │ │ └── AuthContext.jsx +│ │ ├── pages/ +│ │ │ ├── LandingPage.jsx +│ │ │ ├── DashboardPage.jsx +│ │ │ ├── GroupResultsPage.jsx +│ │ │ └── AuthCallbackPage.jsx +│ │ ├── services/ +│ │ │ ├── api.js +│ │ │ └── mocks.js +│ │ ├── utils/ +│ │ ├── App.jsx +│ │ └── main.jsx +│ ├── .env +│ ├── package.json +│ ├── vite.config.js +│ └── eslint.config.js +├── docker-compose.yml +└── README.md +``` + +--- + +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Production Checklist + +- [ ] `ENVIRONMENT=production` +- [ ] `DEBUG=false` +- [ ] rotate all secrets +- [ ] define strict `CORS_ALLOWED_ORIGINS` +- [ ] define strict `TRUSTED_HOSTS` +- [ ] enforce HTTPS via reverse proxy +- [ ] keep DB/Redis private +- [ ] configure monitoring and backups + +--- + +## ![github](https://www.readmecodegen.com/api/social-icon?name=github&size=20&color=%238b5cf6). Authors and Team + +This project is the result of the collaboration with the **University of Jaén**. + +| Role | Developer | GitHub | +| :--- | :--- | :--- | +| **Frontend** | Alexis López Moral | [@AlexisLopez-Dev](https://github.com/AlexisLopez-Dev) | +| **Backend** | Mireya Cueto Garrido | [@MireyaCueto](https://github.com/MireyaCueto) | + +### Direction +* **Proyect Supervisor:** Luis Martínez López + +--- + +

+ Built with professional care and ❤️ for secure research data workflows at the University of Jaén. +

diff --git a/docker-compose.yml b/docker-compose.yml index a0ea598..96075b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: container_name: orcid-backend restart: unless-stopped ports: - - "127.0.0.1:8000:8000" + - "127.0.0.1:8072:8000" env_file: - ./backend/.env environment: @@ -36,7 +36,7 @@ services: container_name: orcid-frontend restart: unless-stopped ports: - - "127.0.0.1:5173:5173" + - "127.0.0.1:8073:5173" depends_on: - backend env_file: From d8fa8031b66384aa06a832fbdd78a25ac8cdb7f7 Mon Sep 17 00:00:00 2001 From: Alexis Date: Tue, 12 May 2026 10:41:45 +0200 Subject: [PATCH 4/5] feat: enhance UI components and improve user experience - Updated Toaster position to bottom-right and added custom styles for success and error messages. - Increased font size of the brand link in AppHeader for better visibility. - Refactored DashboardPage and GroupResultsPage to include a Footer component for consistent layout. - Improved LandingPage with new group input handling and enhanced user feedback for ORCID input. --- frontend/public/uja-logo.png | Bin 0 -> 74499 bytes frontend/src/App.jsx | 12 +- frontend/src/components/layout/AppHeader.jsx | 2 +- frontend/src/components/layout/Footer.jsx | 86 +++++++ frontend/src/index.css | 6 + frontend/src/pages/DashboardPage.jsx | 80 +++--- frontend/src/pages/GroupResultsPage.jsx | 239 ++++++++--------- frontend/src/pages/LandingPage.jsx | 254 ++++++++++++------- 8 files changed, 428 insertions(+), 251 deletions(-) create mode 100644 frontend/public/uja-logo.png create mode 100644 frontend/src/components/layout/Footer.jsx diff --git a/frontend/public/uja-logo.png b/frontend/public/uja-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..77d17fda40748131e22ddd4eb75ad501fe859837 GIT binary patch literal 74499 zcmXWC1yq#J_diakl!9*rkp^ibB&1;l>6Y#mq`PBj1nDJ2TDn0*8ibYZT6&jQSbFId zSayGWzW@I@GtbPO`y6){h@(TyGsEIh^`Qy8Q=&$yQlM0|zIF9S0{o8VBe0p(%V1 z2gmm<4$grU4vxe(92_c-ypE4j52Kc7tLrH|0Fa0>Bq9%s8ickGLRxSjErigAZ#NFK z4hPzegP8Q5d}tpeL?P;+-8zuT7UTnK(gcctLMOQpkZwdb7qVMtau5euhl7A*Kp_wW z0)^;SMIexqkVyoj3^6H(sC&RTV^LTvGy{u8O+peMswgZ9^^gZuu~;mmju{2X!y>v7 zlR8)w5{pItj|uTcc4r_5bx_bUEDDN6AyCjdDC7Y!iI_A&VG&3y67`T!NED(Bi$y&2 zc&H(eC?ui|HK~I{bpQWsEDAD+GYKhs;0!`0GrG%Kpp%5bIylj)I%UCS&_O5)@i5Hb z11MOPE5kOpJMTemED{NUv?LCe$(7mWwUo6?cH<=K5GLwOBA__YIyk{P!GmQFQz8*i z=Q?McU^(ZO#Nd{^=(;@SMAf>%x`#F3M2pL1m}CsrVG)SLhcTg(-B3u`WF29!cw$Q) z3WZ1<%xi%>Fj^3($w5eW3#2Y@uq?VeFLAOC@t_`POZQ}V*<^RiWJ^YO;)80UTk^cS z^P&gKkjOgkmb|>~x=F;~AQF->SeDpbhlS=jcjw`F>p*k_5GLXfTP{Q}7bI8!ks*M{ zd;Xx}Nda*Pm)sr&B zq%--78R0EAsQNGw7Xnd-gs4smFhiKRP<1*F#|VjhSVdi8T^?#ub@E};k!6rN69^Y` zS$7$Zw;Z-R5rr%Z?kehopWi)bXms5ZDt*4 z8pu=@iZ7>BF?NHDKs!;B&|p+b`xG*eOy(ahUaI38&C8W6HeyEBFyzI(R4+oP@P=OU zcQWr#ERgZq|7tH}+@Wy2X-AAL?vfA%yJc~eChY`Y{eRU=|G3qY!00>o`{iCrzijZP z^l4*njS^y8hM;xXzY~5ceS|!ixoMl~aSgKY3Ig2yAJ-P()@c|tzFEH?B~_X`=?cCFW-|c)4^KqLFBRZF z(IqKW7`z0VEx90~qpGk!JQtk>W0D#2wYL7TWz*VNh+iwYtt0>L{{mPywEL!bVDrT_ zTkqmw7m%aiMKC7z0SOOaoxOy9{{zHW13#opa(I!pu_=WbpI8F{50p0p!O<+s8gOya zAtLBqI54B`&RiTjYLU`;Hx0&wJ@nsW3dv?@_wDr_>aJoyHTLEzIq*;QuIwNgT08Sw zWNPB>^JOH014seZD5QI79{}kkQFG4l`I7jKEWkXD@p|fP@0UNRT3#ez0|ms490L#i z0;)qV3)eA=y}-S2@X4vzg9NKsE=jEwM(@sJc(;m01Na2+^TvI>;5(pKrL^!Uh)(*- zPipSuOoDObuHuL;tBW+7L=NePuhI%OuRyUin6Df_LhIjbMCVR5Q@C}mtDFWGhBW#E zeHb8tvy;W&hxRYq==Of4E`gfw@NobE8$|B(Cxrkws*Qdtxh-EasB~!42g^X6pmdIT z5X7Lo=s?n;&QAjQf^{iHzvuLkbmV*=GR?SAvdeugHy`v?|AyUP zy+Zc}WY=owbj)DqAywK%57ah{Q~0K7>1|p??_c&}C>d>@U4QT|Qm>@XOVTWkfe75^ZQ9KkY5qxpGaHw`f1 zGuZWgZP`a}=@9|J`FTtxZ>y9tldZv4>ojzfYyyiZ&h;`fv@-|v@G}t49|jI32SNGP zaL80-I{dl_G|@FF4;qo^EC6#H`B4#dV=gHP4D{8D}x4%%Ez$l+M3xtNQ|eks*xJ5YEOq-<)Je?u6wjgB6J$UHqT*msJ7j zn*LEIG3NuJ^vnQrUatAYR`V%Cx+iOZ5h66(?J8sPAub{Z zXuAUR_`W!lX9qtk52WXu*z2Tth}b^r?x;;GK*YU0zhpEg_4R;h1E{h2aQbW#clDyx z|FqwWrf!}|j~*PK-PFR&QE7&B@07}Pf4shU+cjymm~AzD^@O9AEZ}fW!s{qQ_vfub z`^@&leOM)RG9{a5Uny_7!Z4tx4&OYb2-bWd-V48$`3Q12YnMDevR(k5V?LNGrQ`(Z z=)dbe6G&0<-OUdXH3uz347L>-WQ6|4gzo@?rd`yW6#x2M-@4;OfL}m|WbPUP4X}iZ zPXL;7&L8zZ-mpTG{5VT({<57CxG1G?l`if_FPDn`l@8RtG}YJ4$ml8;{%f9fb-Ish z{!y#qn&3XG8Trdg?pk8W9POYZak5bo0#%YS=WBi6Wp8y%IKEec664I4?&cexJKkY2_%p0OL?Sl6KWOD5tAq{a4S`OF> zUk`*^1HB5jF5ug4FF8kSsp^v7C7ne72~s}6D+Wktyq0?V_B~+^``yV3PVnbvS~#w= zzPNupy<1Ix)L7ngSEb4%@n@7@T`_$1uGjW)g8b?--7?TzQx(M5IZXp%-&Y)lQ}xW@ z`UnS$iqF4x#S;gtsJVS7$uN57ibR-9^X-fdoF{d#mGBX6(D zIIt?{)5WbPMS-cGtw*v<=U=tO5uygQ_A|lJzt`0KNs0>u-)lZvuPOBkvBYjq1~Ev5 zX#{;rIFaI0hl??;VjBTNS_^@Kd#QO0)`HRukVD^D&6n-i4bSlWd5Yn}AD{7Q-WNN+ zH!#xb=Oz>B|EQK0?*Tc>P$kkKPeLZtre<)w)2|P}38-9F6J#{FvTeH0oETF2Bi7T z4gcICJZzS=opw*C27}7swux5Oo%e0?&E2o1o7`4q)9M^!-r*ZlS^sNVS$!3DY5Fa) z^DU^Q5SC84hyQ)Ary+%yx>0NQUtN?G4)6xvwHhJ7fN#jl1f;Ud^;WWTw93K9$+YL}cIVdx(ZEWIHh_1{12lNkt zf_Qm+figE347_atdoZdXau-mD&N#Wk^1`EYT_a?jgS5T#RExsCO;0sVT7C zWG?`K=bMq-n@Qx3*frZ)0u`w#rZ#AqI6lJII0|`W|1BH+IjN)y^tvweXvXF@v?X;t5+4K-j;Rh%bd!R_G{VBMD0dA6nwZ>TuboVNaFn} z_m0M|$yoX|Tkdv!i5J#vS57f+ZBsQi{0%C>Z2|cXZ4bkAbO_wNaoeNp zSe_hWxQ@H0^)8V5o7BV;q1o{DaCO*6QmU`5D#ZmPNetluNbmq{g+UYWeLKOJaqPdY zdf{a7J8FH=om^8C9yt4G@mF~p_)E=pyA)B#cefRJ`}1Ev*!?}84&zQ|+5-RHuj_mQ zempK{zbCuH$4-c)FmAv**NK;o^NORAwDK|*=nw1?C~IPrH1lr^*vXd1E*kd7d*=!m zp$cY1ELUp9u6IRe22Q%~ir-w~u}}%!2(Js1qkKgCF}e8X*NGA1UNC=kz^kUOaNgEO z`d2+;Ct>r!*c^cOeZl8+A5#sUO2@XNWc>K;?t}%Z)}7S;2P0Mg6I?3Z9JkFuNE`=? zu=mOiN}NTp>1Mu2<|U}Y4MtjRBlIuF*%YKjVR3n^MKuIKeN)|wQ^L&P`e zCk3{wuFjDLG(8fG4xJnd>axhb493M6ZoK|i{`Ndmr{_AtA>rCyWh-AOGx7OO6B;zs(Ac?Whmz>3g}UN4L*@4&aD zm`9Q%Xt#uA$yfba`*p`$A#(C>PVjqc@&neV1Nd4$$Fgc=b1Zcgyq5~(avA*+r((*z zna{+;ThyJ=t}c=7BPsC-L>!N19au_w;YWNPg0Ixtrx}(H-*Yc$6S&$0m%`?4%zeLo3TL_5dE8gZ0}rvmiuvH&)R&? zSkOOyLX^vHrmVD7Z3qXbH?LPpr198-ofrP@X16?9rg06YUTkOS#tp8pgMt&Wo4>Q;*m>&-hl7crZ zAhcaC+hohEP|_}RGRt!VY@%u#QhXKk`Ojw~Ft%PKH!sZJfp7o3dVG`He#*W)?G3tm#h;Yh^8TjF+|d zgA}pWEj>Y=1)t$5PVV~2wb(jlx|#52ZWyK%Pk9)lo9r>=(8IPB+`)(6tp#eXxDq*u zLT+DT%8sdCS25qgjzU2rhR=IG^(Yv!s-v7Ki1KP@bu_|ZyLJFau@Rc%7x6X}+4sF~ zFzd|Zehn8X3NT59cMLD1{t6WRbdIy7^iKd`1aoEab{d2s73@K4;-awCfAOO@L#X34U?9ua=?n@3k^KOSZy$579cD|8%mNz7OHN$qV`RF1} zS`%Rl#jorXlcJo(9&|?4_s;mZC*IS|-U=lMhtN zNqw&NIv+WF9SeSFaqfo}sCw_Dr=`TzmS?q~RlN4r!e9 zw9N^EIpbo)KAmkb(VbPHb4?o1nEdrcy{T`Akm zP(qbjnlHr*xbGHu+QofXIuFOymw82zzxjjoXan;RwM|A_n1YbUhKFhMA6U9|?$tI* zq7~hZDA%jFAFG!CV9z}a1kwG0lPOrUj+LR*G3c5pJ%EMI-|ym5X2Eg+fAmZYhpZ$& z9^Q)1H@Y2AIHC!CQT@)BYA@aMZu}9#K^40hi_ZsL$@H5$UgZ&U5Nr_7*FQ6nqWF~IW+Z}Lw4J^#vAmmWt`IK8G5tKO{yplu zqTAt^y^g-(WhoC?`qw}nChm8qt}4rt?c4CbU`5A6l7-^IYu)hoCKgo{YkI1Di8ut;|jbpj!j339U@3;{6*3N!wqoo}y(B z#AtSE58MB=U4gwHLL)46clS+O+8JhITQ-G!0`)r91>}2Yl%%t{%w}=KKkl8!&gUMq_ zZ}6ubj7+HD)q-Pn(PR-rp|EAhS%bhpm5m!!Nr<4?GS06c*E0R>@|60n;CC7HujMsB)|oNKRC*&g7u%#Lv8E1r`qS%Rhz0Ir zc=k5n(q7124)h}Z5KoTy#idY!pG-cN`VL_emEesJrgzvOJcz{BxI?Ozqmbn|qdhe@ zBsmQBwMn&(48J?d$-S4pwxVc2K_af@-d%+g8_2KVPH?2J`ii6dj9}ykXoN2OdhSwI z;b!xdxZ`e4)|33diNsvDAumIpPwkETr$JxO4xK6}lqzH0sfLP9gU1=Xo#Q+kYRHTJ zX6unw%w5`pE{A%@=I)ZaUCt}B97XP9_uETe^pj{*428+bl=rI;tDlW_bWOSACg;%1 z1i{g+oMZlKMoS+z@BJdF**z0aIt7c0UnKp?uaW}u>Mn!f^R4N>1!nYZF@!m}E_^~k z2F(05PB0KcsNvet_kLdFrJuuC0t3%{w`J`P?yz&l0$kh+AsEw3m(kKg*R$L_US;$9 zITJT*q}ZjfJr4n$G89m#!a=jr)Zb;;-raQKU$G0lYgY#NO1sR##6LLwB|UJ6QbrXU>XiT`JZtwh;y5h zEr2pwJqZ7$esiT#Vnc2l2a?O$x;@&~hAH4V=xh(Vlr@>lW#K0G0p)%~AXP;`EPaJ~ zla<|}DlOM--x$HO={u_z^n@XoT9{%6WW&6&bTcsiNa>x zb^&@(Jisrf>N17w&6neG+?}1?Vl9#TMC1L6dT{rrz1A(h8nDVI);J9MXI-U(;# z$sn!+YSxwbSNO zK-)XK-|4;QQZrc*$2wHrUimVW%yst77pUb3KFYAAbgky;sTIV#NJ$>#x{aLv+x^ef z;JlqG7&KZ102D@)0f}<5X&*N8riCEZT9CC!O@pbd}KO@Tu~;%o8*;d_#FBt zVN4OVTfr&}0-2294`ch4uB{23PNc~%1;vFW%Y@o5N=jG_QY(B;#&%KgKH?+SZ9dmN z^AFj4bg4WF?)Kk)E%={KBs)gc*3fQ|u9tB~*_MWTy@1UG#i<|)WwZ>LE)sr>dQBwL ziGnIJN;qfxo_9Q^r`f;La_+2y#dMn9y!{w^2sGAjll`P?kc+nlZiwyp=P`QJm9IIbkr&h0pQ|68_J~THE4x7%o zYJsS+cfv*N;Dok=mOw&ZBTeoKA!9itb;6;^pt*i5!Fcb8`R=u1o5_t2b0PJ;q?~jW znc*6*Lg4eYLu6U`XBNvLGrij%>GJ&Fs?i>=3nx;NnPOKJq&-QNzM3`WtB4a*HayfmP&v9qx8bN_KcX!bC*L3*f- zRa+JnbL%1XM5+e<2pPY_ozlivJHiD zyX8wi*ckQ(0yp~y5T9aPZVd_UCPBejVUI&8Otz&!v5xhCPisS5mwIsvthfca@>~9m zkk(LpZIavsk;+jRN^&{`vDHpovAW~6$|#a>TmZHh2X-E(kNr6PeMfND1{Zi+`}z~} zqTnh#fum(cof!lToNVmt+oNm$5<1l-k&sn^-dmi?p%}4zEnGl0_H(bfWUAR&s9Vo_ z&3qPuWkKeUq@B6 zc*E$*)r>0;;_Gif5o*;#>gat6Yw#x0`<0dFTWcXTy;O zqJ2-$qQm^|&lD4If$qJt>j{d$^UAONo0k!Wp*-j({Au(V9;ehhJ+h!ew-b~N!>d^6 zV&xE?D3#OHIG=9?+QnKUFNNT@m9Knmod-j;-7OH6 z^v@LqNqk@F{wJuL#a*gDzI`Vb|K1#WB^!?La{ykB&hX=%+L3b?7*0>~gU^latQ3`g zne%b4yBTNJb!=9S_z>n2$E<3?!fj~6TEW}buhIW4sA1FMlI+J2(RQsM79_^7=;%FuOO2@hQ-dS2BSVEKAd5NwE+fe(Pvg=X@ z2z~qX=AWwIn|bhH^S}#P%P!gsS8`QBxBb6kBQm;Tg4+8)V8o9TB-5jQht$$Png*cJ zS31TpC@=1Beg4QH;yN;ubhI@m*@Yb^lNpPWuje%WXs3s2fQQywn}4?`dZBh()lLdc z(%fB3V6ZTXSJyGmAre<-v&p{wu{VxpX9q1mm?@MMzZ&C$^U3(5_+StTs)E(=@Qi#{ zQ}SEY-~3~sM4|wt0%L?Z&@)kc%8n0BHG8u{NX&g{{QRVZGX&jGcgd>{H@7XWaK>-l z{MV#CKVq(?^Y~PC)vhzukX$3vsY{>7TMU0t;GdQC{d_q3Qvy`0P^h;;c<|#VJidE2 zhBXar3tz_v8bn~5Ed_O7SZ7-U!;I(tc?eB7a4m`YEfP0{snjdzHh6#bFe*V6qAh-3 zIH#LK5}3iIs1*BdXAHjg?aw+|^b2^ZHKMcXGGdf;<^JEyJ%)QJlZ&E>Y-z`d3Pnuj zr7&;)MV8IJ(N7$G|6yoYL9Jk>w>D?@i;dH>9dq4J&+GU35z{%D^gDiC=Cf%_CEqq9D=L+?8J zxRja{^-Nbxz~$uSP#TG?jtd5G{tzXg`z-FaKlC9Zsw-APtY`>rafoND6gh^W1sI&Q ztsZ+7y|Ptm{$+kXbS3kwi?P2Dx7`%xpIS1B#lLCi|HGS3`Rs0`mr%O!#>*SpR~<5q z;A14Ax7o7k+=Dgy;Q}84EQGQ8m%;1ijsJerSxnnLO3Kcu^AJ5Q^)QIry4+MR*B-XR ztaI^^igt0Zi!59HDk!L8xLP#$)ZSQqElT@uXuX$XPc_eml)KG6V3iJkg{SR?H7er8 z$5efy5T7+WOtkumtC(EC)kc0Lz=fQQ|JJ+2tKjN`_qP#E(bHj#i z%q{IAEMWUK*L}uQPwx@Kev?k;_4lF+MV5;7pI8PhG;8ZZ$q0?Ngp;Lv!${J5Gk>L$ z%JOHH61Fm~VbXxUI+~&>XBl_i5-mOH6~~YM2KS)2q+`CJDM^uywD8`)T#r-swa8rV z+xw#uZnzC6m76RBKD}p9eA+PTLrr|syYvaFV3^rsQP$7ujLFEpTN3R&BL5B2WcuMf&{=PVM=D)84+f7E}Mc9g`C1SeVnF(S$HtlBIJ)y4Gw^yR;*kMq5!Dx*{ z-J9*|e<3eVNr77_1qR=yRTaqv=^%X_?8^HihM^~M@Rl&@{rEnqIjinc{HtB`m(O5y zwI7dFc9CEWV1-f2#fP>4;MXb5XXx6kxwTVD2Ng-Jui7oT-r~Gnc)rq8oj*IiDb#V( z`}|~oX3#0j=&5c_5dUKWj=TH?r>|`3EytP$&!bC~m}cCkaUuT{tQfxf`n+lqUk#}; z;-28iAo9Ki+$Uh?a(ZtiHj%ytu2RU9qOWjVXIX_hrlu`eZ;E@0IBDd4}h=IQL{jj0XP*H7pkGmC(`6p5N+i=5k^jy6(_<+jbn=US22Ny#$e-5o*m2p!|R z*DySEU>IgS*Y3V<0i4%S*XfY8-ij)eH$DA>({a4v@&lp9?7x#~eHlMmeq$J@x&ABm zj%km!@k&SNj{YPFwj=tJPt8-ZO15)heD^o4VCdcXpKG))zwujRNc!z?ijbM|m>oO8 zJ6#cP?K=d`N-u6Ses|Ls(n8ay&+&ZFyWggXAu^$99XP+VY2V zsUexx#^NR1fMJ0cc|M`*rwaV1=R*9#f2C_Vho1F*YwO#*teV?z@fZrRE`o*2uNJLK zM#qXKmX#&w!@RcSAY(_%Gg!4FXlBe(bG>Cz&3j9DA*9j~BM?ofxd!dr~{v?tJ`pPd0JkF^E7p6H|_h6{BMy zhUPIeJfd~C^5xJK3G<{q=?L3>+MnN-)|1UK7b8q^->MPA#l!w^hmWdw{8`SoNo*X9 zvZwZ$!f4mpOw?$afurvJBbcSsm|8OFrD`*rmpOTK@~3VVedyk$4+1#w_IR_DoFF+q zVA*15<$kH`4h~}iQX^Bb9v}Bw{%c_aVz&JCKD~z!%)gzLIy158+YC@o4{PoU>Rn9` z-H2Q2t`~2Q@%!vS{{7(%Da)5Tl~@qUSE!}6<8(1r8NAQU71tx9dm{bv>g&x7v8OL@ z(uv2Dk(gB?6Wig&XUjHfPjY-|FM&^+%oL#1DDHC&vNtg`&DhU~NNT_7_UPa~7+zK{ zhvw*LxZLlF5KjpfVq{4Pf+N6661$e|UpE@wpM+k8df!~Cg;Ml^bBY77ON=U*iI{=7 zBwy(xh=h*^ujCPSFFu!dn#>~oY7;?ue8fqNEG6^?ed8D*Gu$!lEae_jv3(jMP|JAO zYPgGdWBqH^HXz6m*$G;OdZ=^`?F;~AxmB-EfN>Tgy zHAyyo$#<72e-Vq!Z2YeGf`P|9p1R}{1i zC9AQZ#h$ko)6d91+iS)W;qQPtMi@<0g0`#w_|q{H@KgREX?)Zq`qWfOi3jta2ZxgT z6K3H4`k{MYt+i#UTJ;{I9wTC z-fU!lc7?0|a)y~J;?<7~nv-4uQxfvM=V@K(oHpWgo)=$LT#EuNRAu=rXhm8aCLO#V zNBcP5A{P>>|MbA#8fjxfdn@V%ojy%9{sEuF1-1IVn%-FD36c`=VY6SVUu*ksyBGQT zMpVK@l;IUBz45kHZS3;zjei5MZ-qgx%x43s?sWpFLeXOzs%yKWadYriI_H3rpV>7+ z7g>1~53aJ$Z-_2WG}#Bx99h%cPeb$VC-Hzi#S z_qh%lBrel`gtqLrZ_9@RtfvBNVm>ma?#SwkG{(eVlwLbYQzYxZl{L~GwqN#5UTm!l z%ssB02^DZPuN+}fYno3o8p2=oH$uIRlql+LGm0Gq-X*nm5gvE>xkcMyNz?LS;URHc zm5l#v8U&@(G5gd40@H1w9!4rX8J&qg>~4EG!H|IXCAr$618-dhq%9dR_Mma`CX~hWKnGcBlH?nD&syq4;rQb5s5FAISY%U@x9zPZ+c7fHs?#HF?(-xY@pmJ;^HqY zramA9!?sXee2E=Dw8DE>J}@w=JT8-+!;9w5>SEV&gfD~Yu(;_t*+MaCrLPqA;h`Mz zCO&i5{wcHu(rh{cifm+^QQv#5O(o8SW|?gEsk+bkYjDAl_gw*D^xqCdZ$D3;u*Wcj4`I_)%rT1cx3mTfy{$->GW%NY4mi^o?uchEjs=Lx{f1j7T=P%+-`T!+W^!dJ z@O(IGX(s@%>SrMvFV_c<7G8=Xk9f8YHUsNyEuV(pY@FA#_Hvi}(NwJ$Wi<=U2L95t8Y@&QbO1DVn^%&X7u;>oKY%1!w849ZL)#}XP=j2BxM5wFI7LR>`9_5nh=5^vLW$J!kyDL)!h(1w% zGGxR;$sQ~_Jh-W@zt!ISR2p(Hhf757;uojjg!b5TDGce~{uIPb+SD}zvhFr~_NI>P zm)o3wl;_@exu1@xFv{b$$Xmqb`y=Sw01He{b!j9_{{a z&3FOj7`oe>QnEzEh8@zJFVd2Ex|R<-jUMv+qXA$v3&P60Q_*Y-Lq}MPavf3k_&8bq z>04XlgAzTXQt^82CHcACYXPl3(4DJj)BI<9@DirVOEwl}zG;g(%u*}639K=7E^lk} zSWlwnt@zNGIZf{&i3gkSofoUuxVG_25Bt*e-~D)z2mJ6~&jwRtk|@Cfh#;>PXU6A! zayNn*2m8;Kfq_Xbb~Z(tK5AUzcEdBzONBmlCzYV2$inZH*^k+64r4~ar-CQpH>37z z&!UXUW|yn*#{B9NTnM(>#)lY()_JMaTGbYw)Xmnl!Wv0v6~d~I zO5@Lz&a9e>ykFfKURu%JwPUFxZl=wK)>0?%LbyKtfm#}GRz3QnCQ_(@xTw)L(`}*! zfGuCnMv~cfz4<*Nz4S)f{mO<)!HHnP#1IyFXzOpPLNIOkFS#0RJ6f%iGpDpF9q5S< z;yKP%n&K`;FAQFY(gena+K-O~JLdUWZv*zpCmhH;6rtc2?aq1k>vY=c(WSYS-(eNg zzfoUlZg}mM(}*g41ib3mpA%0A7?>V);7TWBQ!3u%D$ZV>Y3vv}g_-lTNa!OLe0i7M zczi?X+Dw%$_cI*qRRaf@JqLv8VY$%>u z81!shi5slhX0C~URkG_Bhk8*iOqn475lhQ>PH}vGal;Muo8uzkj~Pz)WO186 z4-AeCZs$?t=RUXS2@WnsU;EmfEFJqA(}%;MY~aX-w9FerPzR(YEXi=ufsam+zrWtq zD1k>rUm1!YNfL#M2mZ(j>Kx;J5?eIN3T#mP@0;LLr+Xn@cmgA-;U} zr8-xx2Ej3%+GlKHzH-Z;Tv1(W4YguY1IYgJ4ZU1mu_3l2dCrl$N>S~rjg^x@_x zhDsyH@JM*_uy52EpW?qKV)L66RhLT~PFweIH$y6-fcq)ZBB#<8c$grVn89#Qhofc>{A`iZ->aqotn|6^kC+B@L%m7~4j9t`Ti5iDedux?Sn;DSh zFYcekY_(YBcWx)D>OckmdD6Bc%#jh@^{G=>d^kI(hDf+$Kg;bo#K+~7ByH}VFx23g z0RLjxS4;P_KVM(2<+`gTbNu@t2OGxy86R)EpqE!Ay^ccM5Iw=P%RaCAYyxuO&?0ck z?s4?Q6RfE+Kfpv=0m&C|B@sUaDy^8hGWI2J3!G%KsgGSB?`u@f@j|o1FYvkD3REHh zi`Y=&^{v=H@n$F%^EcabbK7r3R-fB3(B!^lBYB(rN}Bie6*_oZKQ5mxQt~$o>$aM} zDn0mR>v~_y4Pj_z(q-51G?jaOox97LY3|N)&EsD-lra8@4u$*v{YxErrQWN2I(Y00 zoqY$*?nh1wJ`xvn3a+0UwEft8$;Tyritr4z<)^VuJ*G1&e14)CAui>f0%p`t@pOrw z1%IUy%wcRv>QpRW=Y6HZ^V2I%h&;ic&hCCq)&%-i2Y62MhJ4D`RFz5CCn~c2TisZ{ zG+6>_e>z_xP*m*W2NMq}Fk0FFwt~$Yy1Wh_gne1O zY~l5BI2rcg#chtfJH4+aGBU{@EH}_xRrlAw#J`kaAlITMuwDB|zZxWicT)OS?JMhy zVMATrUC7LPCLKxXnZupg@$vPT*IuZBE#J<)8!G8VtIn)Xb5DUy?t-GY<%Lg!_)Lr3 zb*Gpd2}Ckv^`E7y$ZNi^?wY$-IZDL}Rn=d7knpIbY0Zgr5eMBA3*znh_ANo)T516f zaUP4@p4~r^B*(4f`H<2U2_IV^=TY;REa!K4(^vsBeHW`!T%G@${)h(156T&(@M3Zlik_|Ft~Wkjxp4UwJvH0`bGKyz+B?~tfodv<$!0`fVZ*HSMZHy0rL8DB=BXCuJqo_MI8teB+$(Ijm+h*V`mR}eNiGWi9T7o(D*(~3yZOn;r&Lx=a4q@@e4y_EMSUj z{YbYZG{rQ}Jo3#`f#9Q}3CKqwFg?7-yw*97tCYjQc97pW@E>U;G~65T2)ALy|JTGS z#`It?DPZ(^Y>CvHlI%<8JATWqceK*~5h~)P;b5*7!pVFTU(6W4it1$hWkHT<&WrQ8 zpva-Wh~l`4fcBhjkQ6zoA8;#v&Ls}fkt|^s&`zD-!Ig&lC1kM6NTDK9sINch#}c8v z2+!;mkgphy1}a}HFCQ&!!aZi~HfR;u_p0=+ZwCCH&3s8xY1NkcZ7ha(_*sm7_)|^R zg5dte6*B`X&GU*aB?%X$t>3B}1hjy~nT7SF&-^_fws-IkZvubm@$+yHZv2VoBnhTJ z>WSuGc@YmCisoIud3+0-Qe*6jv6j$OR~6`w+R4R%ZE?q^_C!8l}g#5Z{82xx`onsxGU-I4H<7Bwpy&}_TK zz7r1%4~&iO7!ln5$UfL${%g?uLU+HTf92Q5xb38W&()e|WGC?ns;_YLtqLI+$6l+Riva+F6)_+FvHOHBJ zZf;?#qLqu8)_Oedj3Fm@4PiwAZt^9JpR;jQZq$CtW{vEh8I{w2fS)l7_mdRX?oKyE zo^%^1@v9FV?$V~EHx0DON`!&m&vWRAC=Jei29H!UUm}-AXMbzv6D(W^F%8^ogb4@v z+FL`YSf@kb3;M-msTEXT&`25BiXg|i@&vHWqeia-lLfa!E*zh$CgUehI~uFR?%N`#`lnND)NC)d|Y0{-d6GK6%bHGi0)fBEs@GL+Gigf4MfvHaqdYqvnjkk>ntB zggKmKaYn$-NYh-@?2nwR*pr*_;Oz%nrb-IvTPO6YTLz%ZV@~pRt4DdEwS@m4IedZO zhx3Jh`_O~iT-Y**J}EnWahv>E{AAdb4&&a^^IR?yU1&yeBA5mK zT5l_O20U--!vspAh`qJ9(n#q+1iw!GfT4Etc%RP$4RD=w)SOJ}7^SEccV!KNQQp(` z4YgU`@=0~BAQ6U7rNCx&|2Pf7(o+)S{`k(fh&c&#Rt@5nK1eNC<&Kgf&{QOx^=mr(O6rDpvb>2{^-RrjUA8h?s+`gMdShiSKz zB{vM%EJX7(9hdBVNdwHX2THAQz;4ucKVAHR^)SByW@5GOQF9*BfjhRi&vCuft?5Q{ z8FZJz(h&3O*h+WFmsdqZ*qhlK^(Lu(g+>F5YnT{fHA)BWnpmL%S>1b%b8txKqM0_@N>0R6qTmS{z~1<1ElC3eB*Z6zx-o&V8HQJz~#{GF^MS-)I7 zJ2`VaN(n-41uW*%swvhna(psPNcuXyS^hyr303|(U&Pb0EeOc+oiMeM#qh(X$$T8_ z3H5cs=Y@vD{bI@Itp4sVyG6?^7cL)nFKjKqUR-QRId1t76fO|-2U#{OOS$7?8?vd$ zFxnpuo>VnOM7Poy&Qbm|67DYiL@!%124-F2H_3 z%7@aospjR_#|M8YC(@%OwYq*+gga03;Am$}+pmpG!8k4^v zdV}OMsBekNfeU}>Q#fzaga^USO`}(kFla3KT#)U>s0g2pm0vsSzU}G$vM($B{{vD$ zt-p^xLDwPqbX9z}+jo%4Yo{l3m(eho8U2a&w0@=*pUTRy00Izee+&H=i4ECUx3e0K zr)-vrU#e!~;zrjUTi5E!O1BZek(wJXSdCSnin z5TB(x@=aX}x4;9122@!Y{x(<~z6AOwT&cIf2pUUTd5qoewf0yJu80CC? z4{LI|25{i$UtdqKrzY`%o*;^HL4@(^wU-yan<*CW&cBJNX$f?H;DL%90~cr0irla+ z4{N>O6+g^5Qox#!4l zQ5C%}sfNSDUpfSCdh8(Kd3FhWgF?Tf0hSH9m7qzFciFhN=8BQB@~=ZSF&(JJBvlkw%@LL z;~Rls=f>LF{n^>IwY7DM;Q{cbQrza#8KHZ!&OzA}-SbgR^D=xC2BeJZSrXH5HqlgedK;U+IosaFYPk+E^mn;Tu zyVT#ytG}8ZO?xqi%_{V5{+m7%M06U(@#cQNMOPv^>G~`4>`1P5ymn!#CZOcOtj?uu z+%knEys+L!rYADldQ`KVAe<(xuq48PRXlYY?qHCdHg?qWcdtc#Pazb83ipvTzg;RW zGClP|ed1+3u(teuk?ur!fxbS!)hc(z~BIFeI1c^!oR>_vVB{ zbi^x$hl#`F1F zy_H!DR$?fh&l#~w`@HS7sq}VC=}Y9>iKz%<6@)3<9J@46M(!{#voQbREMasa677Mb zh79XHK}kQeiQMG<<(mu}3Nt9R+`icv>5K*&p-w)2w1?cgr#>jnEEH?@1R?#c`8Pa> zxV=`F^?n&Gl{-bnY}m!VwbPRsL5r5lPfcaNO_8yxkb@_`%z1jd*Y0*W-JZP^_$MVx zKD`LKLb9sY)n6NmJ9_FzMp-MR^V#gVQ{<1z`|XvTPae#V^~b9P7~@8KypXQpRRA?j zAy2@^&|eVZgc&{*8*`n*X7VeR_;()(mf7E1NN z-(G&*E?Tb;7dxbWJ&8F2eTR7&Zn;jH%DJ)2m$T$CO=TuurI0rNcM7~FMK$#~H1jyU zZa4W&drEn9fP-vif_6I+rZOnEz;D1*T;|}9nT;@$|E{Jdd-af`x~K!v-k++A-%=!} z6E!iN5cQqR>QGGN*~DaA_l0yOQ}488vl;QoBYZ9Bm#%a+zg^x*e@v{@CvGg<%L}EM zTk~XtOQm<$qR6bnLBDyoSe!4{vl%TGZ!f%k@7;JW2t2)1=goQ4 z>hG4A0;{6u%j7kXCb{lr#!YLBje$K$=t0_!JNfZRW#k3OPk~?`d&x8>C-V8xm$6eE;UIHEi^0fC?F3)4?zI7PrN ziE&K$Ki}J{_bErQ zR8GuBu#%5)xy>@&ODhacx(+CE#R{49D&uYl+>MpO1ztE=s1s!N^YTKNF<`MW#UbQY z+zS_Xtjmz=HXmjGEDAS`WH>?|+xpv!ck7eucIPHRad*{ah4vJHAU^ywch6QkI5vjNYu=~$&8&s znEJyl!UDI{kGA$|JROQ%%oGZj>){eUUVdC)j-)N*y}*A_=h3Idezw92s`4n)7)O4V z-i|wk`r{pUw)2ofraugz{~5V;^2EO4LuobGIm<u?04 z4dp#@BH9z|yb4}~;_X{CdqGa#BE4(*cJWQP%O24Gk(2A+N8z}{k@^y$e2#d%5 z(hO_r)AI0XX6;Am1GI>85_xZjOCmGuaPLul=E*1i*zOkdST4K$WK*8$ZT3li{`_GK z$Da%!-^ck$q~$Add*yVeH`9*QAsb6G7F~05rS@Ho1{uR;(-$#xZfr$jktBbPk4#F*4Cr`Zep=IM|%BAIB%`D7ZTi@7N zyGCERTYQ~Fx^Vy>i26u-_&RTv5fRol98how>#;CT(7(x!9rvzDZvQcI>IcK35gDw7 zsy09ox_0<{Wzu`}G=+5Lux|EpF+Owt6!~_h8rH%`ad$W$0)mab5%(u3+OTuqrrojs z*>+(ke{}d8s-}gH9ddGAM^N3>#qHKv%{0QjTg=3<*mFY#hz{x>z;0cBmGr?4c>YE_ z1`AK}f&7gy2sr|N)wZdzqvF>gB%7VR5BW{_0_4p5P)?)Y_w>v{%284ySB(C-{lVJ8 zha{}rKVSTyGZ5&BBpTsPW*`xuYe9NrksP^d-lkmqd0A{Vp*!#vdGqYpV+-Zu=HP&? zRSbMsG0%i`Q~P1g%0i{TqiJ7OlQM;L!^(H)XUpcE8&Q+Vu&ijk{aADc121{q_PrRD ze9|SR8F(9@|8(19&bmHg%1@v9qS^0tRJQy{ZYfcX+bd^9gzP(<)mVjzON(JwRFSQ{WB{R%w(qM7jhS;(&JY!Rgv1J=gHpQ{`taPa_B!;Be(7= zTxTWQO-8ba#s%Zm6)n{hPW5UjTE$F885GmT|cX^@*u(8qRh#dgpZx5ZOC%taO# z%N(;K#YR>+ibk4fkkO6$g6nUWma$_DJ<<0>#T6~HozWAKKqA^WevhDD7db)KBoMq_ z7Z{+ywpjcSQcucc;qA8&dsp}?hV+q&yraANh0KM*L^el1$#V)QQW*85oP-ipn8a4Y z9(7e8)Q|hnvgVPP^vL~@yQ-a%wKxs5lyi#Ge1321>BIiaeSQwvJ>rGXW)W+E`!0H3 zNc{>Hcho-+xEC_-q2-E1J|$-5u&qpFb0dz1J^|4+^>>}@0HwOK0?RE|L8RT$&{ciEDxCJ-e z2V7z~8d$u(X$d=tSMu~IvpHPIWJeEU<@^~HJuC_q6Y9g-u&O3ijM*eL4U~67UXo4^ zuYPcR+AMUYVwIejFa(5dAH>IJW0$K|prz-o1^D4gQ5r^h5xu z`X2PsbOxi5dpEILgg|=sR+H?T+Aa@d;}q>i@x^8~*u^W->`(k*Z@P*5viWD*1nxM$m4hg!>fiqUud> z_KzGpJ6Vm7$alnx6vS3yyZfk$h1YEZuZ4U2g(Bk&tz_>f*sIpPI{(->j*E#njt||J z?p!{d&ThrM%NR;=mqrK2drLRwIQ*NN3t{}GW2g%id?RFRx93?ZFB(+&^xa$ph8Ek-SV|rocn7Vfv@-geNQ9Ydjcn(y8kviaO4;+)a?7r!s2{! zBd|taoL{^v3969a;Cppx)cO{jcWFAz!De6z|K1gUjtq>}7ZK z$5TVG?Rz_I)9HMs&b&|M3)#!~!niFKkJsiT;<1&VF_R6iBFYxX&%2PhxY}$};P%s? z@1p$knfYG_D2ju`_VgSgLr>xSJ%r(~&GXA3BA%Zqtp)#yY(e9C95g~FDtIDU2iBXJ z#dnYlxI0rSZ$vw zQ*d4RqaOu#zuk-at02|?rjGRqE%TItwgc)x)Axa>)n#>gI}=9Swi67tl_`?jTs0+= z7IAs@G72+w<&=&q=#J-|B-x5*H`N{6Gu%nwXRb!cDLdpNVed(xP(#+dbFB<*K;rxJ z(&F;%((UDYQIuX9n|C;f8_Rdg#rKh>DRXU)qK5_Snl7zJefK`NJ5&6yTzqGK=1njn zh?JJYxi6`HS^MfFhb-ybx1ZOvmOk?V`kYz5tQF`V0rsHF%LxVcJNhvOrM0lYyrSMU z9)ef2r~P8fo~XJ9mg2FFonSEj9~U#+i&>YB!Ir6Ukd@T)(%6atT8 zk1*5C9^qbIyjz?lm;Dg8(Qzl04=vO8uKvqHvGgycTdcd6*Q1fn0D7q!=j(zeBI{*Q zdjI(X#-#xM?J~KNLctP;?>?F1L*J{8w`g{xph6SknQ z1P{EG$q(bTQVZpEQu9#d4fQ-a@uI%3T<6$*hU3L@vAnQAZr!aiMFwO?ZkFbk1+VS0 z=(pqim@8o_y-E2QQhk#HgXp?QwiHEa+OvMAShNP3%m)|v{bGp8%iFd?dQw%;;?XMZ zBEAJHK=4Yuq0JGPZvwl4F|MYhZeMknf(c!zQ0VAs-8Y!&=g)8nso zQ_yzgpkZqj?xwh-%cl5lXHSrW^PVUMAJIEQ*8JBu7bxsQ_slxGeSu)Kpk>L@1l1CmCcPCTM!0OnY2aapuu+pcsK*Ulf?-XmE^n>dT?O(7u{YPV4c@3&( zj$-qA!g!FsTwWz551z@II3qk?5_F{;t)cQt-um9zcaRU3O0&^_Ize6=>3h!CGowY| zP;~9XB1-Y=JS^p_=e5~-d_80#g1%_y+cWIjLF#92&(E%t&q%T1Q^7=Mw5M~8Ou+5i za@F>oOakDfh~#EgP!tr*??43JBI3{Z*HYhK4=4+#4aNqiefws^Zp z(&?LMBVEU1{o$7r*oVTKwLErDBF+eIcjh6-S7HadR&!Ie=q#PN*u`Vb9h>$<*lcql z8z+7^QcF>2ZtJk^nY@>NPL3EUPP7dIsfnEA(>Dj>q zX^HiJA04j|E;8{hQ`O{NEPrX;pI5cs-tZhR1hP90cDs*rUS#cN zdAXCKgr0!!*H>r2n?ey?>4P_UbPSCcc27U@G5XWyfXUf8xdr|kWa*XTen*vW%=usq z7|}lv`_@52;x$kY*37|6Nj<43!)j7*x=$qBXD0Q1PRZiBBc1VhC|=#=YV}ve<2Sa# z(X}!@md~co^V|gXe;T&AIn-LJE`2Ix+aV(0RxHx_a~Qv{h$(sT_gn`yinh;Z^*VHU zq$ik2kXZVVYNvR7fdIM}3?z1ubL(`He*oi8k>1UjyX4K6W+^TVMoIi<-Y?#VyLj(@ z>BCwc?{&A!A=b9g|G)p$FPieOrneqHkwWg(zIp^~xL&cd)&4-6qbvK?0bJmu-lxjL z&zr;MAks$3R@Q~}kt^zRxdmq%A%9?hMMSH1S{UeU2x3VT_e_EPB89XK)6ohbK}EOI zvjntYt+jqp0$P2J2l+P*Cm)k<{othdsWi}+jASZYf6#}A(ioR`-8At z{h7rt??MtWMAk+#RaJj}xL3WT%CbJ#6nI4+)D1Vsr5wgN5^)R33hDrOZW|u3=j8Ik zcLQ+^N$(B8DQw-66fb)p+KxO^m=XB#I0!9Mt8}`%yXI`5xt-e~zHfs2-EwgQE!EMU zXh4+b);}oSD!vXL69o0mP$#eNE1g{0^F=oR_J%Le8M$6A-A3xJbdyvD8Gzc`&mfAI zKuKr!dhCvVeGmf^t>?uR6}ubyx+LDvLczDo>v({&+=0rzbxCsij}6F*qRH|ZZS)ya zKcj+ku<7>^R`=OC4aNI`96_UgTQ{%wv|!$R1hr(Gbu^TF7u^8^OMWX0VQsdhr5(ez zxUDko+O;w*{KJXd*otwbW5{NFzg^sC7G|yndH{NW9I@!3!1@B1=)My4aRk>?-~$u` zY+!PhT!83WY3ADh9yk$QU$|R*AHiJl!}UmTtz0bEw{Ctw#vSs`4U$KbaOYZoL{xY z!Fkgj?&X={?dv_Hr@7b~jovTM&y?O|DvL_FX4eBxaO1<9vv027i~7DYTV6-g)m6Ce z4E6lGoq_xHZw}{f*5P$w+dUYB0_H#xTp{fsuMC9s5D&C*72ef;9o}!T<@4;-aZQ)C zWKwxn`OldPnbC7teALwL^r>LhgSBG%-2QkhH5adg%UIeu&c1!e%9bHD;HqN=9e_$RfvHX2v{K(vuwsD6|-TH&!>aFYw&@YHj%GCz#CvT_04=iU#+w|6@=eZuV__X&>EN zPz`ghe2_to&s7Ift7(@V^mVMpOKh{nSEn@Q0aBrD!*zWd+Qlx}$80>fnsa{=OSy}BqvJDPYVTRc4v#~oj z{vK#;GId*I^6Bxe?R{GOjQHx$@|^31r04nJ1@vVVF0QnXdznOVHWG~b0vjKc?v`dj zS``I3X*Am0tpoP~#ryZ(oxfFHSiDs#ljaD{zB6AETS)NB?6uoZ|?p zWvmD&eh7~mTVjec5YI*a_g7HuCprnOTg$)ZjyWGGdLF6?MxF{#fL_hppff8N>8$F3 z9^`0xI^TRB*QZ##i`z20c>Cc~nuyXQG`o@LtU!={YCNs0wnNz@LK$*=+*ee>A>GbP|v zBnNBxp{}7TwmUY%Sh-l~t`Hh|dNp=~6Ehfxs^g&~&D}kf-VQqn^wh=Gxm1k)tuqux zD%E{4)9_q8V*6*%m~O?LOm6umnyk3R>fXk+U)@?RzmJ?9x7vOHLX*zfg@w}m&2kh1A(tZXUhv^3__y;cdb+p{1)6fgI|32Es{3wn{hb& z&uZ;AG-}F8>|^6PkKO&mXWR1Xgcpt}JLyEPIw+_4{I{KyhgYUqTn@lw$xI5NGx z^D51Hd?MF91WwwThj8bh@z0}~UCt;)0tf3PzS2icx^_02=wb4)NH7qD2UlFYJ%be6 z%uMO#n`>)tu3cL!EtJbw*E<8Er!L`(1bgn?+^EHK%wiX*CG0_3i$8Y0yT~nkoS`l4 zc6gGa_`1X65Sz6e?*4H5dl(6eh(i=~Ia}5S?1wqA>JUsEM@ezZfKpSnBsw<-&;A$@ zlAw$o`;Q}mR;&k#gSuQGX7^{={2UM$!4k(tvu{J zy}tu}k2a%*%l9c7pWb24O^{*cGSgjg^5JcD{+h8;*RHeefEnS=4!j503^0pU_dp~O z?d*&mirgocduAS8af_H^y;YiDpx@oSRV*)-me-%c{QzB6=i0);Y|Xuka{qOGTrYr6 zte3fRyFqWTwn@)#g`I92+A!X-lB8~r!D(rX$?l0SG4QCpS{Y zN^U~fbxV#R_vEQhy+;y{Qr%J&car7TcJ8Oo=72mlLtd$i<88K3F0<3aYbUmeo7lqr z!^MT-Eb|ZsL~~6bdSAp^Xn$M4j+oM|+m!{d1mS(S^*udbSS~FuF8unRs<<>USKN8uD15s&L?O#5xtyL@+x^Sf*m_W8Za5FndMI~@8Fx74;|Lt< z(^=q2ldB3wHj?@o6y;2X4E=?I#V;`S?ZFYTJc@}7pAAafFb~{4kvOK8Vu;*Vr+731 z37BkU&bffOCxZ9lks;ep>&BERf&&@lj-A!~Yj)hWDf4?{dTWFA7V}WJIJCq5IC7^J z7trQhp1lR{r;}IdmPkmv_N^0Vy6HnS2Y=CDq+-#ybCn5wft=6?yH4c$5m&IS75Zba#F|(6cuG&dmzW zWXDVQBAq=od(V@R52wQVJD&8;_f)X1@``E{6(PB+FKf4MTWotGF$ z%yKTq+91&6bS4hnyT5#OEqbCS5dENBdNb-vJWej*MseZR%|H+4Y3Q!3-2`U}*lX$D zM1rr+6xYbu`{rvrH;vDH>3XFAPEz}_d}@$vZOi!^MAYiDUbyq%ex@7LBJs%~w-a}b z-!;%%+ZO=1S&i_51d7;T(Pi`1QRaz;91|9&^*&?@t*uie@WINQyJ72kFT0s}Zu7}b z8$+>LUrlUW?D}o^_(Ep7zH7$-6UMH%W0-0??r`aWJK7+7cA0Cg+HrE`qn#T)2->3S z#nMeu37zB&zPos9W}YH^JRqw(dIV4w$o}HIDS9tTU!?xM2P_ez%0v&(C+~(kOqd9JLhbT{y zB^W#gr^L-gcgY^Aea_>s){XUWCcA3X*V!&+o8X?mT;G6Ub!au#54b2h_;tvbl8eq; zVIsli30MN1H-eJs?(s!#-Yulo5g z!__w&7RFt*%)Ivdua4$2wE#OWHbs)lcUsVp!l8G2>J;(n1Qhq>hA`3#b;WB972TW2 zt#-t{uv*t({h+wN!<)NHw{MlNu19^%>#Io5gM*$Ew!W{tUMjB#gX=S;4}X24XZCh! zgFO1q`C@5JWMWWXoiEP3zfimeA8)PP)V^cDrdWF2UXgdn{_iyvBd;y1dF0f?`ztJg zj>jA=@zOgUlUyFLlFqSD-PBj3ArlYP{^Hk0YjbRA@oVzLeKAzx1lhcyfoxuG0hMOHibRPo1dQw zvdf;>D3`8AHx?;QBO9?%oWT~!Yb5tk-XzPz5@M+C8lJq~+^eHIIKNN^*OjAE2ge{n zJANHR{-l%nlDM0>6{4g4sHV8B<&C{Hac7!)@)(joc!4(nAYTVqs?g*|M_<4a%YD`i zE#A;y7cbk+6Y9Gl2|DqWSnctvySZor;Ph&}{ElZbO%z0C+}OdNpDCAT5oQZo@uq$q z_t&AR9{e{X`}fPGwdLEz^4@LO%kygpeNcij)T*0CF6q_r3(CNrnp#sGe}+@`E);@pI=`t z{c4>=pHsU$8@I?c7mgI0)7_VE6-)1vhWJN!Wl^7_UumcDX28VwYS@5xjs-^KsWn&h zhbxPzKt=Pi5~erQl5(%2j=b{SX-k?ou1;qrrs&tP$sZjagfUR%Q+>zj;l~`c^LR%+ z&wt)xC};>|mi+CSW7kzEWYQP1yA@jU-F54|L2sMcC0-zPQdlu|;5`5jIaa>4c$l!7 zP=6}QI|l;6>*ZVX*U8+|#YqJ3vAE7g=r>#4bi{p~E$I2mqS-q4D z>rd{ z*xbe-uuzRnzVwX|dQURw=z%hj5~9QSI*pYA#=H~ zOPVK}xp=VtUK!JcOyjalW`Z7X)c5Dqxn|gWMn;I1%x!y&m;-Mi}=% zuyejtTqn`r?!loOKYFco+?UZ1YnPZ&>X-@Y>0xa|H94FLbBmMcb!v3>IYcwF$Nhx6 z*~rvkcU8|z{$8wy&ME3jqU;MvMe^u=bgCUzV52oUUw3}K|3Dsbi`s1;> ze0%I-#Vj}0#N+jLVWlA2dK#I9OlJIGo%|Zc?h!yfomq+7Y&L z<7JUpCP#uE?8Kre&hUb;c&)US2%^-!4R>U1@3)NmOz92xAEP_c>2|pLq1MeanyImO zjT%W)J1_g~oVW2f|F|i!S->e(f$P`8D)y3p_0(7;F20b-eV5*JP@#ZMO>%R$rDy0m z4$u|+_x_rH;E$y?QJS3JJ-$Zg>q`Be*REWqklw{COmNs&)7y7!@8$D_hH)a@iLM*Q z_Ps(yk+N%^N3#xAkIX}&!GGctlQiHp6|NrrHya2913lYtFB9(3CLK4MpYSeRdM_8& z{4axiKz<$@q)b-vNTYz&vaz05`>`&-E^2#C8mgr_+>dHuC9J4P^%a1{)W0a5kN#D! za!J*{DsG)pX#`3tguC8VU0CXH_G7JMjjt-U0t$=WaL-P4*U`%v2+RukhW0%_F>Tv2 z?zoakH^x1kUy0o?Vms}V;uedRale26ZHn2~VXl2-=^^>Ppdh~zQ&H%s+zxPg9j&?R zE!1TyPguO}Mt3c{W+5HhR!6_}Z~nujVI)<;$exP6#zwen`;dh3dh8f=+dYJT(q1#q zj~^$ELN?npM>D(u&=Q(_X6&WYbnn2J4ZDsFka+VxiXNWz+pG5`AVjMM%qqBnck}DA z7mw7HWtMEKxy&y2B>&!uEmdECr?;C4RpXyGX@?r_Om5OL9veYl@ZJZ4P5kb=8=b)f+4D|L^pSUe2ofJSv0cy4tnu=> zmhTsGyl*jK2@g8P$)an|WUQu@bT&VBe)4Ot93E*L$H{s>Ql`$|0}-ZcSC8H)7z)vU z?f&K<5`MZe@^Am`S#(>N%Gn>Mt@WH)x(Y82F!744p#KJ|M>JIlpj7~)5|ZSPZ8<4p zW$GWQ%wwDQCe!4=A*s`C`)NmY9=@Y%=m?r*Dvzu@rFwC?L)Z&lH}4of!b!e-0qeH1 z*4Tt->mW7Ny>&LH&AowD^@~BbuLQ0kAv1r!GlC*}lxw(E!eadK-r@phY;J9m_rQtP z-)M%ciQVZEd3KM7EUBElXr(An{X}4z&RrNEf8j^ZoF<1)OPa&_(@Cr@FyZhI4m>MK zRz020Zr*%#b*JR;Td*t2sbR1YYbyD2Xa9B#%RkatR9;7aG)RySfO|zlbC0T>bP7Ub z#O{f0xQA>N7&cq=oB%Uln5Mf?GuXyKBfiR8r3(4VRYBFSkjZlk?rY$p?WpzJ7{LoR7tGUfBP#b;a&w>Vv;Jo+F-A8`Z*CHV}aeo^sA@oZX}s( zNmev=D0^pz}bHDnt%tTE&rD@>}dU%bW~2(7b(u3O=wQQ7J~sOAPAg~N zaTD_WQq4`@MbRH{3(P%ENjl0Y0R{V=(Jz_Qo;HyrQj*CZjM9gCASL@$BAW8URaCBwgKm*BSXemdV2$0UQkm~@Xjm;)q*++bKFKTIsc$!(U_N1v{FrE z(<>b$>hX?_I>+vyLI2v8x-bQuspWU`6ZyjVRO-s+Ch7;ry;SNtG4I+7{%1uMVMKueX z+#u|$V5Trg8IK)_$5y8=(`S;$J;_2roCg_N`}{Ty&P?fkKx~8-9eF+AGo`P9zTB4( z9G9r@tp93e{#SJ`f9pD#fS%Un9ok~C^t#JYS@azOVb#}1|L*TzdFi{8qv!KEs~Q2J z2Ztkknp{5ft+V9O$tGefbTS>XRsu0Vcrkv7VNs36g!TrfI(BiWp? zCwIQnq86BoNdAbzJV?P`lJG06?dZ$V7wWI11- z&raJwmJtV;O$~Qa@{s;JF|kwHdi7lHHQSQW@yq&7`;UhEJM;7FAgqNQ_a#{2_q^H3 zMm+)cdLj3Lp1`u;#j2ildP>08vOmxEy)^HXDiZJS9VWpWHY=sMml0f#eeKop^XGFD zVlQ@PBK>2~Qk)4VK`^O;s4f_%8%JLd?FjMVh%5V(wt&CkF9oDu6D$3zW@ zFH;}n$GZ>yH_PP*X=(M*geBSyOn1=t(a73zx&Fo9<*CVgE#odWRTtl=5R*xcqpD)c$j^tvJiRhW za+m(5qUq#%eMd~5rGKnwSQ?8yIQmyPT32Rkn6d*!rpx6*FQ@CcX=MNv8O1h=)HF@} z#v}(`HO))%;9s8477A=je}MZ??V=^GZZoy0rH5_ClJV2-?YLu`bJvx4Y{^EtyPn75 z%74E>e+Hnd;}GD9_ol~Jj>PUTm*Jd^8E~qP2LTcu8t1QeSd+C&SB#-JsEw?SO=Ib! z;ywd1qWgg!pS64d)!2K*yYtsOQNE9y;O%n>`1RMYIIfEGI&Y6}BTu`yP`rC<@oEjZ z{oy`SyQEl0HeZ<_{r8fp4=d&m$3&zwIiQe~_7ZlotPbeC&(<6}ZzKN7&tujeGi`2% z%hv*<4&A9|CgGt^jme}Djyv7F{S)h=`gdfUw&ZgBGCe1o7OW*xQ(4ZuJpk|EM?DYh z*P)K{!J3cRjR=3VVqPzo=I^dWttmOHUK@CwF3Cc1adBbh*8F|W;dWkIX!qUTbUcA( zcnajby;#g!%fo6HkUUkBn)xF!QI?$?sqJT1O-#5&92GO+`n?8y^xf9#Ul34*n)mUk zTpoDlZ*mj4?@;Wn+d#Ewu@r`O{lHfVdF*tB;wD+2!W6X8(IQ$8A9p0uW)|k(T@$n? zOgR43&BfyF`TOXpuWQKaykEM#%)#8kYy$l_fzG$ve!E#QxrBUJOd%6n#r+H^`h5QK zPZZ@xNS{+E|1FS?Rq>Hc!ad}uv{yPBt(7|P;D zZtMu`F1b(YLZOlWrF_4 z+Ou_gA;4y5Dkr6iU)XRI{t3y2rZ_m|&gS%Vx(O#QIb!xc_qQrJ^MlR`?vkTF7H>9{sK%L+Kh_c7W$y>7rw3zK*mpn3ao3XVQ)S-; z@D+}d<@@WMKEiaCy!ZQ^!RlOOFyadagS>342YtHOFFsqk+y47q6~Q(7FGArOUoG7L zu_YE$$=U3MmneWEKflJ0p(t1_*I&u^IUajr#}yPlG|hVtU_!&up*HW^Rk6EJQ-{?v z=(Uj_$3`0$Z+4HwV@6}##b4Nd$@m+BH8}PF>tP-i?mP=o23Zjk;NS(`zP>?0T)9YY ze$BooxF;tX*zb!zmFNkOum{(9=C$4TTX>6ir#r}R4#<5~fkwOQIqqc1PvkQfzGEur zgHdY=f!sP-)ectjeFW}~gK{psGDaxL#2-_d@09?5CB3dbtSXlV<$YGKH|%>TwrL;C zHsC4#8iII}H)l(MKGN8r*+X z5?xt$u`h-jb;`w=nT6u))zZ!A_W8PCCx+=s4cwb8T1&v&J9lT^b$i_&hs*7BIj)?k ze*S&BI`}u0M93>>;U^`>l$hMWi8zDd@ZHtduK&%Z{t2D-%59XzR*TAN8jb^+!2ioxPKox zb}F_;CbhxyC{yM$+1x9dqBaC9C30!J49>Sh^j^yc+D|G<>-)k6@ZgZAV&5gdk>byI z$dWWm%#9m2xRoX~Cq!_l5qc@HFaWt$7Wm4sJcd+bK}RaSs%8Y$J(Jec_p;O4p^u3B z0@GWT%Cq3K^F`K6G>@a6xaoeHw^4?9f>o0;Rtqrv@@3dR>GO0@sWBe^CYo{wr zBe@q%ZBTBA`*A0;6th_F5W#zExcgQRU##7H8KVPl;Rc!hQOs` z&RA+J?@LTrDppKbFba)ftqp0S=i`=BY1phN0q;excVqh_KN{}k5_UY_zsCjm1h~&O z#K_DxdAH`PqReAD`+jjAEmW=7XNsknw?sb-srsNw(KCfn=HZN(JIm${4=PDjZmj47 z7WJsz?(lAd{mDAq6RO_V`d*VhIjp~omOL*P-$jnrs913$CR}w}m4S55lFWwMIwrX3 zc?t9ttH1+$&0lPs&t*@Mbi63P@H^UuhAQ;NJ2;vj5%*$oW?{Ayw0DH)iRimC3tXhx z$-1MVKEe}pK~!t#nczTc{el%|NlWtvb;D2K0(k72QA=Hm3qicMJe zhISgOVZ^mx8m-v!~JAZR@%T_Nh%4;sk3uGWvJ%U0rnNl(dM(2CffA) zYN+aP?WE`L+(;Q$mSU^eA8`3{rXZe{A74!^#bQg1^Fd@9<~q8%&tI%?D@;vIO~424D2G?e=%cAACS;Tkw zrd{k1C1sq{wA0UJEIzmMFKbEtj69+@9*mVwJFU_ToUg68JKR;=-Q=fft>aEM!Sp*V z6XN)TP%J)YNjods)r*&_y_8@|$nxTV&@O3Q1G^(ur@><>n)s|RpP1n!t}~qte%H@k8r;pJ;7a+z97~{zgaBJVt8WvK6oa=1LV=@p#+)ups#$p zywE!CrQ7r^US~>o1WkUOWc#%BIo!9;Nl}K=Er#MM4mH}Kmb#~R;1SQ0%pX}wg@%q8b#svL_OLSIiY>L&HNNdx zXe62#KSGLvw@2398(X~V3Ox-z!1#`Hl-cuL^tv}Xz>+(c`pCG?AltgQ1}>avG=ctk z!g{S-oF&kM^i^L1eV2{-Qli&qitn`XeqbxUTI-+KH}GFgZ88l~@6pp{bDHu#^(jcD zzf4o9GtCL#5@dSQq87?R`6)HLk%Qa$gu@YwI5(Z2g) zW+&eHOgjHwH#QW&MX=SL)d6S0W?SmIxZ}8d;c`0H9r}LEwq!f910Qv}u+Jmua(kKW zcodtad}yd#dmp>Qdq8g1!+o`lc{8GKf`+yY-0`MwRprvqD1twiVg7$@q`6*J^pMr- zivMltqTgTZzjGV~?yU!M^vFY%8ZSz))wrG9x%>{ip%r4<)upAbc%6S2zahLmTZo5V zb^tJ!zW_4vIP&{9>hyhwh#Z;5c>DaF_y$%DH0Q^6;HzvG_jkCF;yzQ6bN?i>*xoDM zUhatmNa>^gCYDGyI6WKXQaiY_7x-oso`6Q4w3*9iCI{ijH%+uC`cX?NQG40q*!gR7 zagJkp3ssrg(ac_FWv>Ldr(>14J+_LmmYwF|Fb0v&b=g=-D=?(EMK0EHWtE-4ZTJ;< zG$?&}Wy!cQv^n{D12na(g4VRb++}uU$kw$K+kx+*UEDb>KYyPaXaiy@3R`F1y*0n# z`wB8Q*eV!}H0D2y_RN=xZP<6guvfFg1ARIv`po12_68@L@9~q519vym-ELhl<*woG zH_2C!Tca#?bISO2-=j%|l|*tTKk?E1~GApO=8M17T^Uz@)* z`;~x5)FyZzNrOYa{nffLnig>9y`ojWTiQ3MnyDnrd?=+q&cKP8qW4tP^YNtD(udf^-|G)9fdu}2(HGSlu zF;qj43phhv=d-+ZU}}eF6(;D?Th^oX2 zwKmoeD|*0T?H`sLp(^awBB=Z}9s�bS7O`N!80=hY?TB*|QRg;I1TY#Mmv0;~7*g%14_)Sfe?lxV@0A%rkC1mbj;q@a8Io(b?* z6$)UT{MqVI%2p#tz|P+;9-7T%3p)_tV%7$f+1XVa*c3i0?&WvzwAj99{M|^0e zLAPw@eTuk>H?QC0`KiYISib0N@txM&FUq_RW2V+e|AeXaokE{G)_|DJ1yt)jD%dF8 z4#|CBlVcZf3#v0D15fjdsV(E)w@(aDV+nE48cw)kGj`eNRTcdF`71Ry%f`#wrwdJ= zm&r`mFOi0QUmDwev5Yulwkx)ej(hdYuXXxPFi9;F9*~sZyUO*J`D<&PQC|Y|Rq*~2 zf#@N0*RfAWsGsoNzb)FW#0tR{kDF;gL2mU&rlJkBp^yg#t7Nwh&n9N8Kj3}cjJsbM zrXVgUw=VBS1$RkG2;AdWY^yE6m(8+IUo+G(Tlae{;GTZ3drMiaJJzj3&SSZW=-Fr)__rL) z7ENlkOK!!i*8tLzen)MNep8lKa#e5-m|=N9F-KaDOfm2Qdyla@}` z)mi2U!d!Cz!Fl;!oxGDb7`xblmM6#Wy&HQF$5qZuX<@mz`1ZTkH>{|fS5c52-&nu) zcFE$&D&JgN2hSb-MUU7|69Hd!dEr(Y0lYXjNzz^XIs0Y3odyYdaKBU?m#WA8$vWH( z{fzmo!VY|`Moo$YLAy1BbcP+0^xOcbkZa7n` z#vbHttn=Z|MNw^Wes&`&XzVZt|5Ol@G8=2NEceB7dEsAf{$B)lA|MzMBH+4OEH5vV z%i?OZ`hLr9$?f9NQ|TCp#@fJLk5%{&2;{maSzo(0k9L{T!u_707>kcYPDB&G{@_EaAG_FkAkWkn!jn3vXszRp>4|;S zJw=-`wE+p-cA}H#m@bc*ng14|sSyk~`>8WtqQu4}DZv9hP7Ud!OJi>|9@-xm8@e zJHNqu#RzxoAnLq7GgEQtTfS#+T22&r$%k9#qX+KC1-2r;U&GxadC_aje`lI{5^0Xf zRu>UJ<*nj=r;2+%-4wq9{WFmnufcAMFHP^bCvf|X$xPp=;eNxI&S&$@?E@IIuEh5Y z_Yx1x6lY2meSPyygmmC2#iH;YOv2NDzKwa_LHo`hM3L9yZQ)Vhq0Wu9>$BHdXQ!sO zyN)$q6vw%pQNq2AekfBn>v(SJGW22@3u%k#y~XEM$81ZM#EP~Nwm z<R?aSqLU+~b0KveYKV4$Kic)yLeFW>Z_S&BbtU27xG z)=fo?I%-6ubxjvoO(3*?R9BM!37miQK~5e-fE^#^4D;00d`L2 z)#09^fNUz;T;73jArq@7vi*&~y)<*1!n&Z9r|S_khQVNPwtTAy+;6|j%jr0O8^EgQ z&gk{|Hu`GIZ(IACNOpVM#9i;NbsM`T?pJ>{+j~4va<-Pn?WzJkWxhJ?k>5yD*3%-!v zEB_^bhL_Qjn*Z@&@5onWIjQdu&tqYgZmYzF4ynH~*9yUHilAU&tJ~~l#NyM7S6Ip72CJ|A

m#Tr>pH-Tqz?q~gXI{0eGmLFI_qEgd8C6mL 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 17b6d82..76683e6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,11 +24,21 @@ export default function App() { ); diff --git a/frontend/src/components/layout/AppHeader.jsx b/frontend/src/components/layout/AppHeader.jsx index 029e66c..a846ae6 100644 --- a/frontend/src/components/layout/AppHeader.jsx +++ b/frontend/src/components/layout/AppHeader.jsx @@ -30,7 +30,7 @@ export function AppHeader({ variant = "landing" }) { {/* Brand — always navigates home */} ORCID2SWORD diff --git a/frontend/src/components/layout/Footer.jsx b/frontend/src/components/layout/Footer.jsx new file mode 100644 index 0000000..c8b7654 --- /dev/null +++ b/frontend/src/components/layout/Footer.jsx @@ -0,0 +1,86 @@ +export default function Footer() { + const technologies = ["ORCID OAuth 2.0", "SWORD v2", "DSpace", "EPrints", "Dublin Core"]; + + return ( +

+
+ + {/* Main row */} +
+ + {/* Brand */} +
+
+ + ORCID2SWORD + + + Software Universitario + +
+

+ Sincronización de publicaciones ORCID al repositorio institucional. +

+
+ + {/* Compatible con */} +
+ + Compatible con + +
+ {technologies.map((tech) => ( + + {tech} + + ))} +
+
+ + {/* Institutional links */} +
+ + {/* Universidad de Jaén */} + +
+ Universidad + de Jaén +
+ Logo UJA +
+ + {/* Repositorio Oficial */} + +
+ Repositorio + Oficial +
+ +
+ +
+
+ +
+
+ ); + } \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 32818c8..704dfbe 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -55,6 +55,12 @@ --color-tag-default-text: #5F5E5A; --color-tag-default-border: #D3D1C7; + /* Error (hue-0° mirrors of the ORCID green palette — same HSL lightness & saturation) */ + --color-error-vivid: #CE3939; + --color-error-soft: #F3DDDD; + --color-error-border: #DD9797; + --color-error-text: #6E1111; + /* Fonts */ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index cdd9a17..276d7e9 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -3,6 +3,7 @@ import { useLocation, useParams, Navigate } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; +import Footer from "../components/layout/Footer"; import { ResearcherCard } from "../components/dashboard/ResearcherCard"; import { StatsRow } from "../components/dashboard/StatsRow"; import { PublicationsTable } from "../components/dashboard/PublicationsTable"; @@ -196,53 +197,42 @@ export function DashboardPage() { return (
+
+
+ {researcher ? ( + + + + + } + /> + ) : ( + + )} -
- {researcher ? ( - - - - - } + + + loadBundle()} + selectedIds={selectedIds} + onSelectedIdsChange={setSelectedIds} + isAuthenticated={isAuthenticated} /> - ) : ( - - )} - - - - loadBundle()} - selectedIds={selectedIds} - onSelectedIdsChange={setSelectedIds} - isAuthenticated={isAuthenticated} - /> - -
- - Datos obtenidos vía ORCID Public API v3.0 - -
- {["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => ( - - {t} - - ))} -
-
-
+
+
+
); } diff --git a/frontend/src/pages/GroupResultsPage.jsx b/frontend/src/pages/GroupResultsPage.jsx index 8a29ad4..5991b95 100644 --- a/frontend/src/pages/GroupResultsPage.jsx +++ b/frontend/src/pages/GroupResultsPage.jsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate, Link } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; +import Footer from "../components/layout/Footer"; import { Spinner } from "../components/ui/Spinner"; import { OrcidLogo } from "../components/ui/OrcidLogo"; import { @@ -188,135 +189,137 @@ export function GroupResultsPage() { return (
+
+
+ {/* Page header */} +
+
+
+ +
+
+

+ Búsqueda grupal +

+ {!loading && ( +

+ {results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""} + {errors.length > 0 && ( + + · {errors.length} con error + + )} +

+ )} +
+
-
- {/* Page header */} -
-
-
- -
-
-

- Búsqueda grupal -

- {!loading && ( -

- {results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""} - {errors.length > 0 && ( - - · {errors.length} con error - - )} -

- )} -
+ {/* Global export buttons */} + {!loading && results.length > 0 && ( +
+ {["xml", "zip"].map((fmt) => ( + + ))} +
+ )}
- {/* Global export buttons */} + {/* Loading state */} + {loading && ( +
+ +

+ Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID... +

+

+ Esto puede tardar unos segundos si hay muchos perfiles nuevos. +

+
+ )} + + {/* Results grid */} {!loading && results.length > 0 && ( -
- {["xml", "zip"].map((fmt) => ( - +
+ {results.map((bundle) => ( + + handleCardExport( + bundle.researcher?.orcid_id, + fmt, + newIds, + totalIds, + ) + } + /> ))}
)} -
- {/* Loading state */} - {loading && ( -
- -

- Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID... -

-

- Esto puede tardar unos segundos si hay muchos perfiles nuevos. -

-
- )} - - {/* Results grid */} - {!loading && results.length > 0 && ( -
- {results.map((bundle) => ( - - handleCardExport( - bundle.researcher?.orcid_id, - fmt, - newIds, - totalIds, - ) - } - /> - ))} -
- )} - - {/* Errors */} - {!loading && errors.length > 0 && ( -
-

- ORCID iDs que no pudieron cargarse -

-
- {errors.map((e) => ( -
- -
-

- {e.orcid_id} -

-

- {e.detail ?? "No se pudo obtener información de este ORCID."} -

+ {/* Errors */} + {!loading && errors.length > 0 && ( +
+

+ ORCID iDs que no pudieron cargarse +

+
+ {errors.map((e) => ( +
+ +
+

+ {e.orcid_id} +

+

+ {e.detail ?? "No se pudo obtener información de este ORCID."} +

+
-
- ))} + ))} +
-
- )} + )} - {/* Empty state */} - {!loading && results.length === 0 && errors.length === 0 && ( -
- -

No se encontraron resultados.

- - - Volver al inicio - -
- )} -
+ {/* Empty state */} + {!loading && results.length === 0 && errors.length === 0 && ( +
+ +

No se encontraron resultados.

+ + + Volver al inicio + +
+ )} +
+
+
); } diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 6793a2c..8a9ce27 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -1,15 +1,17 @@ import { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; -import { DocumentIcon, UsersIcon } from "../components/ui/Icons"; +import { UsersIcon } from "../components/ui/Icons"; import { OrcidLogo } from "../components/ui/OrcidLogo"; import { Spinner } from "../components/ui/Spinner"; +import { getInitials } from "../utils/formatters"; import { formatOrcidInput, isValidOrcid } from "../utils/orcid"; import { getOrcidAuthorizeUrl, searchResearcher } from "../services/api"; import { useAuth } from "../contexts/AuthContext"; import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; +import Footer from "../components/layout/Footer"; /** * Entry view: login con ORCID iD + búsqueda individual anónima + @@ -21,7 +23,22 @@ import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; */ export function LandingPage() { const navigate = useNavigate(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, userName, userOrcidId } = useAuth(); + + // If the JWT doesn't carry the name (ORCID sandbox omits it), fetch it + // lazily from the public researcher endpoint so the identity block is correct. + const [resolvedName, setResolvedName] = useState(null); + useEffect(() => { + if (!isAuthenticated || !userOrcidId) { setResolvedName(null); return; } + if (userName) { setResolvedName(userName); return; } + let cancelled = false; + searchResearcher(userOrcidId) + .then((bundle) => { if (!cancelled) setResolvedName(bundle.researcher?.name ?? null); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [isAuthenticated, userOrcidId, userName]); + + const displayName = resolvedName ?? userName; const [orcidInput, setOrcidInput] = useState(""); const [error, setError] = useState(""); @@ -29,9 +46,11 @@ export function LandingPage() { const [loginLoading, setLoginLoading] = useState(false); // Group search state - const [groupInput, setGroupInput] = useState(""); + const [groupTags, setGroupTags] = useState([]); + const [groupRawInput, setGroupRawInput] = useState(""); const [groupError, setGroupError] = useState(""); const [groupLoading, setGroupLoading] = useState(false); + const groupInputRef = useRef(null); // Cleanup refs for popup polling interval const popupRef = useRef(null); @@ -126,28 +145,60 @@ export function LandingPage() { } } - function parseGroupOrcids(raw) { - return raw - .split(/[\s,\n]+/) - .map((s) => s.trim()) - .filter(Boolean); + /** + * Splits a raw string on comma/space/newline separators, promotes valid + * ORCIDs to tags (deduplicating against existing ones), and returns any + * leftover invalid tokens joined by a space so the user can correct them. + */ + function commitRawInput(raw) { + const parts = raw.split(/[\s,\n]+/).map((s) => s.trim()).filter(Boolean); + const valid = parts.filter(isValidOrcid); + const invalid = parts.filter((p) => !isValidOrcid(p)); + if (valid.length > 0) { + setGroupTags((prev) => [...new Set([...prev, ...valid])]); + if (groupError) setGroupError(""); + } + return invalid.join(" "); + } + + function handleGroupTagKeyDown(event) { + const { key } = event; + if (key === "Enter" || key === "," || key === " ") { + event.preventDefault(); + const leftover = commitRawInput(groupRawInput); + setGroupRawInput(leftover); + } else if (key === "Backspace" && groupRawInput === "" && groupTags.length > 0) { + setGroupTags((prev) => prev.slice(0, -1)); + } + } + + function handleGroupRawChange(event) { + setGroupRawInput(event.target.value); + if (groupError) setGroupError(""); + } + + function handleGroupPaste(event) { + event.preventDefault(); + const pasted = event.clipboardData.getData("text"); + const combined = groupRawInput ? `${groupRawInput} ${pasted}` : pasted; + const leftover = commitRawInput(combined); + setGroupRawInput(leftover); + } + + function removeGroupTag(tag) { + setGroupTags((prev) => prev.filter((t) => t !== tag)); + if (groupError) setGroupError(""); } async function handleGroupSearch() { - const ids = parseGroupOrcids(groupInput); - if (ids.length === 0) { - setGroupError("Introduce al menos un ORCID iD."); - return; - } - const invalid = ids.filter((id) => !isValidOrcid(id)); - if (invalid.length > 0) { - setGroupError(`ORCID iDs con formato incorrecto: ${invalid.join(", ")}`); + if (groupTags.length === 0) { + setGroupError("Introduce al menos un ORCID iD válido."); return; } setGroupError(""); setGroupLoading(true); try { - navigate("/group", { state: { orcidIds: ids } }); + navigate("/group", { state: { orcidIds: groupTags } }); } finally { setGroupLoading(false); } @@ -157,32 +208,20 @@ export function LandingPage() { if (event.key === "Enter") handleValidate(); } - function handleGroupKeyDown(event) { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleGroupSearch(); - } - } - return (
-
+
{/* ── Hero ── */}
-
- -
-

- Tu producción científica,{" "} - siempre al día. +

+ Tus publicaciones, listas para depositar.

- Sincroniza tu perfil ORCID y deposita tus publicaciones - automáticamente vía SWORD. + Conecta tu ORCID y descárgalas en XML cuando quieras.

@@ -190,26 +229,45 @@ export function LandingPage() {
{/* ── Left: individual search + login ── */} -
+
{isAuthenticated ? ( -
- Sesión activa - - Verás publicaciones nuevas marcadas en el dashboard - -
- ) : ( - +
+ {getInitials(displayName ?? userOrcidId ?? "?")} +
+
+

+ {displayName ?? "Mi Perfil"} +

+
+ + {userOrcidId ?? "—"} +
+

+ Verás publicaciones nuevas marcadas en el dashboard +

+
+ + ) : ( + <> + +

+ Actualizamos tus publicaciones automáticamente cada mes. +

+ )}
@@ -272,10 +330,10 @@ export function LandingPage() {
{/* ── Right: group search ── */} -
+
- -

+ +

Búsqueda grupal de investigadores

@@ -283,55 +341,79 @@ export function LandingPage() { Pega varios ORCID iDs separados por comas, espacios o saltos de línea para buscar y comparar varios investigadores a la vez.

-