Merge pull request #18 from uja-dev-practices/develop

V1.0
This commit is contained in:
Alexis López
2026-05-12 12:35:51 +02:00
committed by GitHub
48 changed files with 1863 additions and 548 deletions
+8
View File
@@ -4,6 +4,7 @@
.env.*
!.env.example
# --- PYTHON BACKEND ---
__pycache__/
*.pyc
@@ -22,6 +23,13 @@ ENV/
# FastAPI / Uvicorn
*.pid
# Test / type checkers
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# --- NODE FRONTEND ---
node_modules/
dist/
+20
View File
@@ -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/
+70 -8
View File
@@ -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
# ============================================================
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: "<n>/<window>")
# ============================================================
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
+29 -3
View File
@@ -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"]
+74 -38
View File
@@ -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)
+123 -82
View File
@@ -1,167 +1,208 @@
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
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
# ---------------------------------------------------------
@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_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()
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_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()
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")
+52 -36
View File
@@ -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.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.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
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,18 @@ 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)
@router.post(
"/search",
response_model=ResearcherBatchSearchResponseSchema,
response_model_exclude_none=True,
)
@limiter.limit(settings.RATE_LIMIT_SEARCH_ANON)
def search_and_sync_researchers(
request: Request,
payload: ResearcherBatchSearchRequestSchema,
db: Session = Depends(get_db),
current: Researcher | None = Depends(get_optional_current_researcher),
@@ -196,26 +200,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 +239,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),
):
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 +265,6 @@ def sync_researcher(
groups = works.get("group", [])
publications_output = []
new_count = 0
updated_count = 0
unchanged_count = 0
@@ -277,21 +297,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,
View File
+35
View File
@@ -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)
+183
View File
@@ -0,0 +1,183 @@
"""
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: str = ""
TRUSTED_HOSTS: str = "*"
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
@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:
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 cors_origins:
raise ValueError(
"CORS_ALLOWED_ORIGINS no puede contener '*' en producción."
)
if not cors_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 trusted_hosts == ["*"]:
raise ValueError(
"TRUSTED_HOSTS debe definirse explícitamente en producción."
)
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}")
return self
@property
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
@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"]
+67
View File
@@ -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)
+28
View File
@@ -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)
+66
View File
@@ -0,0 +1,66 @@
"""
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.
- Si hay cabecera X-Forwarded-For (ngrok, nginx, cualquier proxy inverso),
usa la primera IP de la cadena (la del cliente real).
- En caso contrario, usa la IP remota del socket.
"""
researcher = getattr(request.state, "researcher", None)
if researcher is not None:
return f"user:{getattr(researcher, 'orcid_id', None) or researcher.id}"
forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for:
client_ip = forwarded_for.split(",")[0].strip()
return f"ip:{client_ip}"
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=False,
strategy="fixed-window",
)
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"},
)
+75
View File
@@ -0,0 +1,75 @@
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-allow-popups")
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)
if "server" in response.headers:
del response.headers["server"]
if "x-powered-by" in response.headers:
del response.headers["x-powered-by"]
return response
+4
View File
@@ -1,3 +1,7 @@
from sqlalchemy.orm import declarative_base
# ---------------------------------------------------------
# Base de datos
# ---------------------------------------------------------
Base = declarative_base()
+9
View File
@@ -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):
"""
@@ -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):
"""
@@ -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()
@@ -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"
+10
View File
@@ -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)
+119 -33
View File
@@ -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://<host>/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=["*"],
)
+10
View File
@@ -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()
+6
View File
@@ -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
+23
View File
@@ -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,
)
+4
View File
@@ -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
+29 -6
View File
@@ -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):
+34 -25
View File
@@ -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
+94 -31
View File
@@ -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)
+76
View File
@@ -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())
+6
View File
@@ -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
+10
View File
@@ -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,
*,
+3
View File
@@ -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:
+15
View File
@@ -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.
+5 -1
View File
@@ -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 = [
+15 -4
View File
@@ -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
+5 -3
View File
@@ -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
+30 -10
View File
@@ -3,9 +3,9 @@ services:
backend:
build: ./backend
container_name: orcid-backend
restart: always
restart: unless-stopped
ports:
- "8000:8000"
- "127.0.0.1:8072:8000"
env_file:
- ./backend/.env
environment:
@@ -17,28 +17,43 @@ services:
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:8073: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 +61,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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+11 -1
View File
@@ -24,11 +24,21 @@ export default function App() {
</Routes>
<Toaster
position="top-right"
position="bottom-right"
richColors
closeButton
theme="light"
toastOptions={{ duration: 4000 }}
style={{
/* SUCCESS — ORCID corporate green */
'--success-bg': '#EAF3DE',
'--success-border': '#C0DD97',
'--success-text': '#3B6D11',
/* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */
'--error-bg': '#F3DDDD',
'--error-border': '#DD9797',
'--error-text': '#6E1111',
}}
/>
</AuthProvider>
);
+1 -1
View File
@@ -30,7 +30,7 @@ export function AppHeader({ variant = "landing" }) {
{/* Brand — always navigates home */}
<Link
to="/"
className="text-[15px] font-bold tracking-tight text-white transition-opacity hover:opacity-90"
className="text-[16px] font-bold tracking-tight text-white transition-opacity hover:opacity-90"
>
ORCID<span className="text-orcid-green">2</span>SWORD
</Link>
+86
View File
@@ -0,0 +1,86 @@
export default function Footer() {
const technologies = ["ORCID OAuth 2.0", "SWORD v2", "DSpace", "EPrints", "Dublin Core"];
return (
<footer className="mt-auto w-full shrink-0 border-t border-surface-border bg-surface-primary py-6">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Main row */}
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
{/* Brand */}
<div className="flex flex-col gap-2 lg:max-w-xs">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base font-extrabold tracking-tight text-ink-primary">
ORCID<span className="text-orcid-green">2</span>SWORD
</span>
<span className="rounded border border-orcid-green-border bg-orcid-green-soft px-1.5 py-0.5 text-[10px] font-black uppercase tracking-widest text-orcid-green-text">
Software Universitario
</span>
</div>
<p className="text-sm leading-relaxed text-ink-secondary">
Sincronización de publicaciones ORCID al repositorio institucional.
</p>
</div>
{/* Compatible con */}
<div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-ink-tertiary">
Compatible con
</span>
<div className="flex flex-wrap gap-1.5">
{technologies.map((tech) => (
<span
key={tech}
className="rounded border border-surface-border bg-surface-secondary px-2 py-0.5 text-[11px] font-medium text-ink-tertiary"
>
{tech}
</span>
))}
</div>
</div>
{/* Institutional links */}
<div className="flex flex-row gap-6 sm:gap-8">
{/* Universidad de Jaén */}
<a
href="https://www.ujaen.es/"
target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-2.5"
title="Ir a la web oficial de la Universidad de Jaén"
>
<div className="flex h-8 flex-col justify-center border-r-2 border-surface-border-strong pr-2.5 text-right transition-colors group-hover:border-brand-accent">
<span className="mb-0.5 text-[11px] font-bold uppercase leading-none tracking-wide text-ink-primary">Universidad</span>
<span className="text-[10px] font-medium uppercase leading-none tracking-[0.22em] text-ink-tertiary">de Jaén</span>
</div>
<img
src="/uja-logo.png"
alt="Logo UJA"
className="h-7 w-7 object-contain grayscale opacity-80 transition-all group-hover:grayscale-0 group-hover:opacity-100"
/>
</a>
{/* Repositorio Oficial */}
<a
href="https://github.com/uja-dev-practices/orcid_system"
target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-2.5"
title="Ver repositorio oficial"
>
<div className="flex h-8 flex-col justify-center border-r-2 border-surface-border-strong pr-2.5 text-right transition-colors group-hover:border-brand-primary">
<span className="mb-0.5 text-[11px] font-bold uppercase leading-none tracking-wide text-ink-primary">Repositorio</span>
<span className="text-[10px] font-medium uppercase leading-none tracking-[0.22em] text-ink-tertiary">Oficial</span>
</div>
<svg className="h-7 w-7 text-ink-tertiary transition-colors group-hover:text-brand-primary" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
</a>
</div>
</div>
</div>
</footer>
);
}
+14
View File
@@ -78,6 +78,20 @@ export function AuthProvider({ children }) {
return () => window.removeEventListener("message", handleMessage);
}, []);
// Fallback when postMessage cannot reach the opener (e.g. browser policy
// severs window.opener during the OAuth redirect chain). localStorage is
// shared between same-origin windows, so the popup's `setItem(...)` fires
// a storage event in this window and we can pick up the new token.
useEffect(() => {
function handleStorage(event) {
if (event.key !== STORAGE_KEY) return;
if (event.newValue) setToken(event.newValue);
else setToken(null);
}
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
/**
* Stores a JWT directly (used by AuthCallbackPage).
* Does NOT trigger any network request.
+6
View File
@@ -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;
+34 -15
View File
@@ -36,6 +36,7 @@ export function AuthCallbackPage() {
hasHandledCodeRef.current = true;
const code = searchParams.get("code");
const state = searchParams.get("state");
const oauthError = searchParams.get("error");
const errorDescription = searchParams.get("error_description");
@@ -69,7 +70,7 @@ export function AuthCallbackPage() {
}
sessionStorage.setItem(consumedKey, "1");
exchangeOrcidCode(code)
exchangeOrcidCode(code, { state })
.then(({ access_token }) => {
storeToken(access_token);
setStatus("success");
@@ -87,16 +88,29 @@ export function AuthCallbackPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// After a short delay, redirect to home if we're NOT in a popup
// (fallback for browsers that block window.open).
// After a short delay, always attempt window.close():
// - If we're in the OAuth popup (opened by window.open()), the browser
// allows close() and the window disappears.
// - If the window doesn't close (browser blocked it, or the user opened
// /callback directly as a plain tab), we detect that via window.closed
// and fall back to navigating to "/" so the user sees the landing page.
//
// NOTE: Neither window.opener nor window.name are reliable here.
// - window.name is cleared by Chrome on cross-origin navigation
// (our domain sandbox.orcid.org our domain clears the name).
// - window.opener is severed by ORCID Sandbox's own COOP header
// while the popup passes through their domain.
useEffect(() => {
if (status === "success" || status === "error") {
const isPopup = Boolean(window.opener);
if (!isPopup) {
const timer = setTimeout(() => navigate("/"), 2000);
return () => clearTimeout(timer);
}
}
if (status !== "success" && status !== "error") return;
const outer = setTimeout(() => {
window.close();
// Give the browser a tick to process the close. If the window
// is still open, we're in a plain tab navigate to home instead.
setTimeout(() => {
if (!window.closed) navigate("/");
}, 300);
}, 1500);
return () => clearTimeout(outer);
}, [status, navigate]);
return (
@@ -154,9 +168,16 @@ export function AuthCallbackPage() {
/* ─────────────────────────── Helpers ───────────────────────────── */
/**
* If running in a popup, posts a message to the opener and closes the
* window. If not in a popup (e.g. browser blocked it), the message is
* irrelevant the useEffect above handles the redirect to "/".
* If running in a popup, posts a message to the opener so the parent
* window can update its auth state without waiting for the storage event
* fallback in AuthContext. The actual `window.close()` is handled by the
* delayed effect above so we don't race with the success/error UI.
*
* `window.opener` may be `null` here when the browser severed the opener
* relationship during the OAuth redirect chain (some COOP combinations
* trigger this). In that case AuthContext picks up the new token via the
* `storage` event instead that's why we still call `storeToken()` even
* when we can't postMessage.
*/
function notifyAndClose(message) {
if (window.opener && !window.opener.closed) {
@@ -165,8 +186,6 @@ function notifyAndClose(message) {
} catch {
/* opener may have navigated away */
}
// Small delay so the user sees the success/error state before close.
setTimeout(() => window.close(), 1200);
}
}
+35 -45
View File
@@ -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 (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="dashboard" />
<main className="flex-1">
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{researcher ? (
<ResearcherCard
researcher={researcher}
actions={
<>
<SyncButton onClick={handleSync} status={syncStatus} />
<ExportDropdown
onExport={handleExport}
exportingFormat={exportingFormat}
selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length}
/>
</>
}
/>
) : (
<ResearcherSkeleton />
)}
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{researcher ? (
<ResearcherCard
researcher={researcher}
actions={
<>
<SyncButton onClick={handleSync} status={syncStatus} />
<ExportDropdown
onExport={handleExport}
exportingFormat={exportingFormat}
selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length}
/>
</>
}
<StatsRow publications={publications} />
<PublicationsTable
publications={publications}
loading={pubsLoading}
error={pubsError}
onRetry={() => loadBundle()}
selectedIds={selectedIds}
onSelectedIdsChange={setSelectedIds}
isAuthenticated={isAuthenticated}
/>
) : (
<ResearcherSkeleton />
)}
<StatsRow publications={publications} />
<PublicationsTable
publications={publications}
loading={pubsLoading}
error={pubsError}
onRetry={() => loadBundle()}
selectedIds={selectedIds}
onSelectedIdsChange={setSelectedIds}
isAuthenticated={isAuthenticated}
/>
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
<span className="text-xs text-ink-tertiary">
Datos obtenidos vía ORCID Public API v3.0
</span>
<div className="flex gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => (
<span key={t} className="text-xs text-ink-tertiary">
{t}
</span>
))}
</div>
</footer>
</div>
</div>
</main>
<Footer />
</div>
);
}
+121 -118
View File
@@ -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 (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="group" />
<main className="flex-1">
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{/* Page header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary text-white">
<UsersIcon size={20} />
</div>
<div>
<h1 className="text-xl font-semibold text-ink-primary">
Búsqueda grupal
</h1>
{!loading && (
<p className="text-xs text-ink-tertiary">
{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
{errors.length > 0 && (
<span className="ml-1 text-ink-danger">
· {errors.length} con error
</span>
)}
</p>
)}
</div>
</div>
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{/* Page header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary text-white">
<UsersIcon size={20} />
</div>
<div>
<h1 className="text-xl font-semibold text-ink-primary">
Búsqueda grupal
</h1>
{!loading && (
<p className="text-xs text-ink-tertiary">
{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
{errors.length > 0 && (
<span className="ml-1 text-ink-danger">
· {errors.length} con error
</span>
)}
</p>
)}
</div>
{/* Global export buttons */}
{!loading && results.length > 0 && (
<div className="flex gap-2">
{["xml", "zip"].map((fmt) => (
<button
key={fmt}
type="button"
onClick={() => handleGlobalExport(fmt)}
disabled={globalDisabled}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-4 py-2 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{globalExporting === fmt ? (
<Spinner size={14} />
) : isAuthenticated && allNewIds.length > 0 ? (
<SparkleIcon size={13} className="text-brand-accent" />
) : (
<DownloadIcon size={14} />
)}
{globalExporting === fmt
? `Exportando ${fmt.toUpperCase()}...`
: `${fmt.toUpperCase()} · ${globalLabel}`}
</button>
))}
</div>
)}
</div>
{/* Global export buttons */}
{/* Loading state */}
{loading && (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-ink-tertiary">
<Spinner size={28} />
<p className="text-sm">
Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
</p>
<p className="text-xs text-ink-tertiary/60">
Esto puede tardar unos segundos si hay muchos perfiles nuevos.
</p>
</div>
)}
{/* Results grid */}
{!loading && results.length > 0 && (
<div className="flex gap-2">
{["xml", "zip"].map((fmt) => (
<button
key={fmt}
type="button"
onClick={() => handleGlobalExport(fmt)}
disabled={globalDisabled}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-4 py-2 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{globalExporting === fmt ? (
<Spinner size={14} />
) : isAuthenticated && allNewIds.length > 0 ? (
<SparkleIcon size={13} className="text-brand-accent" />
) : (
<DownloadIcon size={14} />
)}
{globalExporting === fmt
? `Exportando ${fmt.toUpperCase()}...`
: `${fmt.toUpperCase()} · ${globalLabel}`}
</button>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{results.map((bundle) => (
<ResearcherResultCard
key={bundle.researcher?.orcid_id}
bundle={bundle}
isAuthenticated={isAuthenticated}
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
onExport={(fmt, newIds, totalIds) =>
handleCardExport(
bundle.researcher?.orcid_id,
fmt,
newIds,
totalIds,
)
}
/>
))}
</div>
)}
</div>
{/* Loading state */}
{loading && (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-ink-tertiary">
<Spinner size={28} />
<p className="text-sm">
Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
</p>
<p className="text-xs text-ink-tertiary/60">
Esto puede tardar unos segundos si hay muchos perfiles nuevos.
</p>
</div>
)}
{/* Results grid */}
{!loading && results.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{results.map((bundle) => (
<ResearcherResultCard
key={bundle.researcher?.orcid_id}
bundle={bundle}
isAuthenticated={isAuthenticated}
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
onExport={(fmt, newIds, totalIds) =>
handleCardExport(
bundle.researcher?.orcid_id,
fmt,
newIds,
totalIds,
)
}
/>
))}
</div>
)}
{/* Errors */}
{!loading && errors.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-medium text-ink-secondary">
ORCID iDs que no pudieron cargarse
</h2>
<div className="space-y-2">
{errors.map((e) => (
<div
key={e.orcid_id}
className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3"
>
<AlertIcon size={16} className="mt-0.5 shrink-0 text-red-500" />
<div>
<p className="font-mono text-[13px] font-medium text-red-700">
{e.orcid_id}
</p>
<p className="text-xs text-red-500">
{e.detail ?? "No se pudo obtener información de este ORCID."}
</p>
{/* Errors */}
{!loading && errors.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-medium text-ink-secondary">
ORCID iDs que no pudieron cargarse
</h2>
<div className="space-y-2">
{errors.map((e) => (
<div
key={e.orcid_id}
className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3"
>
<AlertIcon size={16} className="mt-0.5 shrink-0 text-red-500" />
<div>
<p className="font-mono text-[13px] font-medium text-red-700">
{e.orcid_id}
</p>
<p className="text-xs text-red-500">
{e.detail ?? "No se pudo obtener información de este ORCID."}
</p>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
)}
)}
{/* Empty state */}
{!loading && results.length === 0 && errors.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-ink-tertiary">
<UsersIcon size={32} className="opacity-30" />
<p className="text-sm">No se encontraron resultados.</p>
<Link
to="/"
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-primary-hover"
>
<ArrowLeftIcon />
Volver al inicio
</Link>
</div>
)}
</div>
{/* Empty state */}
{!loading && results.length === 0 && errors.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-ink-tertiary">
<UsersIcon size={32} className="opacity-30" />
<p className="text-sm">No se encontraron resultados.</p>
<Link
to="/"
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-primary-hover"
>
<ArrowLeftIcon />
Volver al inicio
</Link>
</div>
)}
</div>
</main>
<Footer />
</div>
);
}
+168 -86
View File
@@ -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 (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="landing" />
<main className="flex flex-1 flex-col items-center px-4 py-12">
<main className="flex flex-1 flex-col items-center px-4 pb-12 pt-16">
<div className="w-full max-w-7xl">
{/* ── Hero ── */}
<div className="mb-12 text-center">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-primary shadow-[0_4px_24px_rgba(11,61,107,0.18)]">
<DocumentIcon size={32} className="text-white" />
</div>
<h1 className="mb-3 text-[32px] font-bold tracking-tight text-ink-primary md:text-[40px]">
Tu producción científica,{" "}
<span className="text-brand-primary">siempre al día.</span>
<h1 className="mb-3 text-[36px] font-bold tracking-tight text-ink-primary md:text-[46px]">
Tus publicaciones, listas para depositar.
</h1>
<p className="mx-auto max-w-xl text-[16px] leading-relaxed text-ink-secondary">
Sincroniza tu perfil ORCID y deposita tus publicaciones
automáticamente vía SWORD.
Conecta tu ORCID y descárgalas en XML cuando quieras.
</p>
</div>
@@ -190,26 +229,45 @@ export function LandingPage() {
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
{/* ── Left: individual search + login ── */}
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
<div className="flex flex-col rounded-2xl border border-surface-border/30 bg-surface-primary p-8 shadow-sm">
{isAuthenticated ? (
<div className="flex items-center justify-between rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
<span className="font-medium">Sesión activa</span>
<span className="text-xs text-green-600">
Verás publicaciones nuevas marcadas en el dashboard
</span>
</div>
) : (
<button
type="button"
onClick={handleOrcidLogin}
disabled={loginLoading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-5 py-3.5 text-[15px] font-semibold tracking-wide text-orcid-green-dark transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
<Link
to={userOrcidId ? `/dashboard/${userOrcidId}` : "/"}
className="flex w-full items-center gap-3 rounded-xl p-2 -mx-2 transition-colors hover:bg-surface-secondary"
>
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
{loginLoading
? "Abriendo ventana de ORCID..."
: "Iniciar sesión con ORCID"}
</button>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-primary text-base font-semibold text-white">
{getInitials(displayName ?? userOrcidId ?? "?")}
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-ink-primary">
{displayName ?? "Mi Perfil"}
</p>
<div className="mt-0.5 inline-flex items-center gap-1.5 text-sm text-ink-secondary">
<OrcidLogo size={14} />
<span className="font-mono">{userOrcidId ?? "—"}</span>
</div>
<p className="mt-1 text-xs text-ink-tertiary">
Verás publicaciones nuevas marcadas en el dashboard
</p>
</div>
</Link>
) : (
<>
<button
type="button"
onClick={handleOrcidLogin}
disabled={loginLoading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-6 py-4 text-[16px] font-semibold tracking-wide text-orcid-green-dark shadow-sm transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
>
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
{loginLoading
? "Abriendo ventana de ORCID..."
: "Iniciar sesión con ORCID"}
</button>
<p className="mt-2.5 text-center text-sm text-ink-tertiary">
Actualizamos tus publicaciones automáticamente cada mes.
</p>
</>
)}
<div className="my-6 flex items-center gap-3">
@@ -272,10 +330,10 @@ export function LandingPage() {
</div>
{/* ── Right: group search ── */}
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
<div className="flex flex-col rounded-2xl border border-surface-border/20 bg-surface-secondary p-8 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<UsersIcon size={18} className="text-brand-accent" />
<h2 className="text-[15px] font-semibold text-ink-primary">
<UsersIcon size={16} className="text-ink-tertiary" />
<h2 className="text-[14px] font-semibold text-ink-secondary">
Búsqueda grupal de investigadores
</h2>
</div>
@@ -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.
</p>
<textarea
rows={5}
placeholder={"0000-0002-1825-0097\n0000-0001-5000-0007\n0000-0003-4321-9876"}
value={groupInput}
onChange={(e) => {
setGroupInput(e.target.value);
if (groupError) setGroupError("");
}}
onKeyDown={handleGroupKeyDown}
className={`w-full flex-1 resize-none rounded-lg border px-3.5 py-3 font-mono text-[13px] text-ink-primary outline-none transition-colors ${
{/* ── Tag input area ── */}
<div
role="textbox"
aria-multiline="true"
aria-label="ORCID iDs"
onClick={() => groupInputRef.current?.focus()}
className={`flex min-h-[120px] cursor-text flex-wrap content-start gap-1.5 overflow-y-auto rounded-lg border px-3 py-2.5 transition-colors ${
groupError
? "border-border-danger"
: "border-surface-border-strong focus:border-brand-accent"
: "border-surface-border-strong focus-within:border-brand-accent"
}`}
/>
>
{groupTags.map((tag) => (
<span
key={tag}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[#b8d4ea] bg-[#deeef9] py-0.5 pl-1.5 pr-1 font-mono text-[11.5px] font-medium text-[#1a4a6b] transition-colors hover:bg-[#cce3f4]"
>
<OrcidLogo size={12} />
{tag}
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeGroupTag(tag); }}
aria-label={`Eliminar ${tag}`}
className="ml-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full text-[#1a4a6b]/50 transition-colors hover:bg-[#1a4a6b]/15 hover:text-[#1a4a6b]"
>
×
</button>
</span>
))}
<input
ref={groupInputRef}
type="text"
value={groupRawInput}
onChange={handleGroupRawChange}
onKeyDown={handleGroupTagKeyDown}
onPaste={handleGroupPaste}
placeholder={
groupTags.length === 0
? "Pega o escribe ORCID iDs separados por comas, espacios o saltos de línea"
: ""
}
className="min-w-[200px] flex-1 bg-transparent font-mono text-[13px] text-ink-primary outline-none placeholder:text-ink-tertiary/60"
/>
</div>
{groupError && (
<p className="mt-1 text-xs text-ink-danger">{groupError}</p>
)}
<button
type="button"
onClick={handleGroupSearch}
disabled={groupLoading || !groupInput.trim()}
className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3 text-sm font-medium transition-colors ${
groupInput.trim()
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
: "bg-surface-secondary text-ink-tertiary"
} disabled:cursor-not-allowed`}
disabled={groupLoading || groupTags.length === 0}
className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
groupTags.length > 0
? "bg-brand-primary text-white hover:bg-brand-primary-hover"
: "border border-surface-border bg-surface-secondary text-ink-tertiary"
}`}
>
{groupLoading && <Spinner size={14} />}
<UsersIcon size={14} />
{groupLoading ? "Preparando..." : "Buscar investigadores"}
{groupLoading
? "Preparando..."
: groupTags.length > 1
? `Buscar ${groupTags.length} investigadores`
: "Buscar investigadores"}
</button>
</div>
</div>
{/* ── Info chips ── */}
<div className="mt-8 flex flex-wrap justify-center gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "DSpace · EPrints"].map((label) => (
<span
key={label}
className="rounded-full border border-surface-border/60 bg-surface-secondary px-3.5 py-1.5 text-xs text-ink-tertiary"
>
{label}
</span>
))}
</div>
</div>
</main>
<Footer />
</div>
);
}
+4 -2
View File
@@ -237,9 +237,11 @@ export function getOrcidAuthorizeUrl() {
* Intercambia el authorization code (recibido de ORCID tras el OAuth)
* por un JWT propio del backend. Devuelve `{ access_token, token_type }`.
*/
export async function exchangeOrcidCode(code, { signal } = {}) {
export async function exchangeOrcidCode(code, { state, signal } = {}) {
const params = { code };
if (state) params.state = state;
return request(
`/auth/orcid/callback?${new URLSearchParams({ code }).toString()}`,
`/auth/orcid/callback?${new URLSearchParams(params).toString()}`,
{ signal },
);
}