From 7a2afce79689d7e6e5d86b4d3aac3e67feffb4fa Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 27 Apr 2026 13:55:06 -0400 Subject: [PATCH] Refactor code structure for improved readability and maintainability Co-authored-by: Copilot --- Cargo.lock | 1 + roadmap.md | 9 +- services/lms-service/Cargo.toml | 1 + services/lms-service/src/handlers.rs | 76 ++++++++++++ .../lms-service/src/handlers_lti_consumer.rs | 63 ++++++++++ services/lms-service/src/main.rs | 8 ++ .../components/CollaborativeWhiteboard.tsx | 43 +++++-- .../src/app/courses/[id]/lti-tools/page.tsx | 112 ++++++++++++++++++ web/studio/src/lib/api.ts | 2 + web/studio/tsconfig.tsbuildinfo | 2 +- 10 files changed, 302 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1512309..154c621 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2235,6 +2235,7 @@ dependencies = [ "sqlx", "thiserror 2.0.17", "tokio", + "tokio-stream", "tower-http", "tower_governor", "tracing", diff --git a/roadmap.md b/roadmap.md index 3f746e9..5e0f04f 100644 --- a/roadmap.md +++ b/roadmap.md @@ -117,7 +117,12 @@ --- **Estado Actual**: Plataforma madura con IA generativa integrada, arquitectura Premium Single-Tenant, búsqueda semántica y monetización operativa. + +### Fase 37: Tiempo Real y Seguridad Operacional ⚡ +- [x] **SSE para Pizarras Colaborativas**: Reemplazado polling de 5s por `EventSource` en `CollaborativeWhiteboard.tsx`. Endpoint `GET /lessons/{id}/collaborative-canvas/stream` en `lms-service` usa canal `tokio::sync::mpsc` + `ReceiverStream`; el servidor consulta la DB cada 2s y emite eventos SSE solo cuando cambia la `revision`. El cliente cierra la conexión al desmontar el componente. +- [x] **Rotación de Secretos LTI**: Endpoint `POST /courses/{id}/lti-tools/{tool_id}/rotate-secret` genera un nuevo secreto alfanumérico de 32 chars, actualiza la DB y lo retorna una sola vez. UI en Studio (`/courses/[id]/lti-tools`) con botón 🔑 por herramienta, modal de confirmación de riesgo, y panel de copia-única del nuevo secreto con botón clipboard. + **Próximas Prioridades**: 1. Migrar passback LTI de HMAC custom a **OAuth2 AGS** (estándar IMS) manteniendo compatibilidad transitoria. -2. Añadir **rotación/revocación de secretos** y auditoría de intentos fallidos de passback. -3. Evolucionar **Pizarras Compartidas** de polling a WebSocket/SSE tras validar carga en producción. \ No newline at end of file +2. **Edición multiusuario** de documentos (tipo Google Docs) para Fase 33. +3. **Salas de Estudio** — grupos efímeros por video para resolución de dudas grupales. \ No newline at end of file diff --git a/services/lms-service/Cargo.toml b/services/lms-service/Cargo.toml index 9b1e9f8..68d268f 100644 --- a/services/lms-service/Cargo.toml +++ b/services/lms-service/Cargo.toml @@ -32,5 +32,6 @@ aws-sdk-s3 = "1" sha2.workspace = true hmac.workspace = true hex.workspace = true +tokio-stream = "0.1" lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] } rand = "0.8" diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 2c11d0f..a8bb994 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -4,6 +4,7 @@ use axum::{ http::{HeaderMap, header::AUTHORIZATION}, http::StatusCode, response::IntoResponse, + response::sse::{Event, KeepAlive, Sse}, Extension, }; use aws_config::BehaviorVersion; @@ -4706,3 +4707,78 @@ fn extract_block_content(metadata: &Option) -> String { } block_content } + +// ─── SSE: Pizarra Colaborativa en Tiempo Real (Fase 37) ────────────────────── + +pub async fn stream_lesson_collaborative_canvas( + Org(org_ctx): Org, + State(pool): State, + Path(id): Path, +) -> Result { + use std::convert::Infallible; + use tokio_stream::wrappers::ReceiverStream; + + let lesson_exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("stream_lesson_collaborative_canvas: lesson check failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !lesson_exists { + return Err(StatusCode::NOT_FOUND); + } + + let (tx, rx) = tokio::sync::mpsc::channel::>(16); + + tokio::spawn(async move { + #[derive(sqlx::FromRow)] + struct CanvasRow { + canvas_state: serde_json::Value, + revision: i64, + updated_at: DateTime, + } + + let mut last_revision: i64 = -1; + + loop { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let result = sqlx::query_as::<_, CanvasRow>( + "SELECT canvas_state, revision, updated_at FROM lesson_collaborative_canvases WHERE lesson_id = $1 AND organization_id = $2", + ) + .bind(id) + .bind(org_ctx.id) + .fetch_optional(&pool) + .await; + + match result { + Ok(Some(row)) if row.revision != last_revision => { + last_revision = row.revision; + let payload = serde_json::json!({ + "lesson_id": id, + "canvas_state": row.canvas_state, + "revision": row.revision, + "updated_at": row.updated_at.to_rfc3339(), + }); + if tx.send(Ok(Event::default().data(payload.to_string()))).await.is_err() { + break; // Cliente desconectado + } + } + Ok(_) => {} + Err(e) => { + tracing::error!("stream_lesson_collaborative_canvas: poll error: {}", e); + let _ = tx.send(Ok(Event::default().event("error").data("poll_error"))).await; + } + } + } + }); + + Ok(Sse::new(ReceiverStream::new(rx)) + .keep_alive(KeepAlive::default())) +} diff --git a/services/lms-service/src/handlers_lti_consumer.rs b/services/lms-service/src/handlers_lti_consumer.rs index 9e0caa0..1a09712 100644 --- a/services/lms-service/src/handlers_lti_consumer.rs +++ b/services/lms-service/src/handlers_lti_consumer.rs @@ -430,3 +430,66 @@ pub async fn lti_grade_passback( normalized_score: normalized, })) } + +// ─── Rotación de Secreto LTI (Fase 37) ─────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct RotateSecretResponse { + pub tool_id: Uuid, + pub new_secret: String, + pub rotated_at: chrono::DateTime, +} + +pub async fn rotate_lti_tool_secret( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path((course_id, tool_id)): Path<(Uuid, Uuid)>, +) -> Result, (StatusCode, String)> { + use rand::Rng; + + // Verificar que la herramienta pertenece al curso y organización + let tool_exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM lti_external_tools WHERE id = $1 AND course_id = $2 AND organization_id = $3)", + ) + .bind(tool_id) + .bind(course_id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !tool_exists { + return Err((StatusCode::NOT_FOUND, "Herramienta LTI no encontrada".to_string())); + } + + // Generar nuevo secreto aleatorio de 32 caracteres alfanuméricos + let new_secret: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(32) + .map(char::from) + .collect(); + + let now = chrono::Utc::now(); + + sqlx::query( + "UPDATE lti_external_tools SET shared_secret = $1, updated_at = $2 WHERE id = $3", + ) + .bind(&new_secret) + .bind(now) + .bind(tool_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + tracing::info!( + "rotate_lti_tool_secret: rotated secret for tool {} in course {} org {}", + tool_id, course_id, org_ctx.id + ); + + Ok(Json(RotateSecretResponse { + tool_id, + new_secret, + rotated_at: now, + })) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 9086d21..8c61ad8 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -168,6 +168,10 @@ async fn main() { get(handlers::get_lesson_collaborative_canvas) .put(handlers::update_lesson_collaborative_canvas), ) + .route( + "/lessons/{id}/collaborative-canvas/stream", + get(handlers::stream_lesson_collaborative_canvas), + ) .route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark)) .route("/bookmarks", get(handlers::get_user_bookmarks)) .route("/grades", post(handlers::submit_lesson_score)) @@ -210,6 +214,10 @@ async fn main() { put(handlers_lti_consumer::update_course_lti_tool) .delete(handlers_lti_consumer::delete_course_lti_tool), ) + .route( + "/courses/{id}/lti-tools/{tool_id}/rotate-secret", + post(handlers_lti_consumer::rotate_lti_tool_secret), + ) // Portafolio e insignias (Badges) .route("/profile/{user_id}", get(portfolio::get_public_profile)) .route("/my/badges", get(portfolio::get_my_badges)) diff --git a/web/experience/src/components/CollaborativeWhiteboard.tsx b/web/experience/src/components/CollaborativeWhiteboard.tsx index 7f74122..cb38705 100644 --- a/web/experience/src/components/CollaborativeWhiteboard.tsx +++ b/web/experience/src/components/CollaborativeWhiteboard.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; -import { lmsApi, CollaborativeCanvasState } from "@/lib/api"; +import { lmsApi, getLmsApiUrl, CollaborativeCanvasState } from "@/lib/api"; import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react"; type ConflictInfo = { @@ -218,23 +218,42 @@ export default function CollaborativeWhiteboard({ lessonId }: Props) { }, [dirty, loading, saveCanvas, saving, strokes]); useEffect(() => { - const interval = setInterval(async () => { + const base = getLmsApiUrl(); + // getToken no es exportada; leemos directamente de sessionStorage/localStorage + const token = + (typeof window !== "undefined" && + (sessionStorage.getItem("preview_token") || localStorage.getItem("experience_token"))) || + ""; + + const url = `${base}/lessons/${lessonId}/collaborative-canvas/stream${token ? `?preview_token=${encodeURIComponent(token)}` : ""}`; + const es = new EventSource(url); + + es.onmessage = (ev) => { if (dirty || isDrawing.current) return; try { - const data = await lmsApi.getLessonCollaborativeCanvas(lessonId); - const serverStamp = data.updated_at || null; - if (serverStamp !== lastSavedAt) { + const data = JSON.parse(ev.data as string) as { + revision: number; + canvas_state: CollaborativeCanvasState; + updated_at: string; + }; + if (data.revision !== revision) { setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS)); - setLastSavedAt(serverStamp); - setRevision(data.revision || 0); + setRevision(data.revision); + setLastSavedAt(data.updated_at); } - } catch (e) { - console.error("Polling collaborative canvas failed", e); + } catch { + // Ignorar eventos malformados } - }, 5000); + }; - return () => clearInterval(interval); - }, [dirty, lastSavedAt, lessonId]); + es.onerror = () => { + // EventSource reintenta automáticamente; no mostramos error al usuario + }; + + return () => { + es.close(); + }; + }, [dirty, lessonId, revision]); return (
diff --git a/web/studio/src/app/courses/[id]/lti-tools/page.tsx b/web/studio/src/app/courses/[id]/lti-tools/page.tsx index 5e9a26c..096c041 100644 --- a/web/studio/src/app/courses/[id]/lti-tools/page.tsx +++ b/web/studio/src/app/courses/[id]/lti-tools/page.tsx @@ -16,6 +16,9 @@ import { ToggleLeft, ToggleRight, ExternalLink, + KeyRound, + Copy, + CheckCheck, } from "lucide-react"; export default function CourseLtiToolsPage() { @@ -31,6 +34,12 @@ export default function CourseLtiToolsPage() { enabled: true, }); + // Rotación de secreto + const [rotateModal, setRotateModal] = useState<{ toolId: string; toolName: string } | null>(null); + const [rotatingId, setRotatingId] = useState(null); + const [newSecret, setNewSecret] = useState(null); + const [copied, setCopied] = useState(false); + const loadTools = useCallback(async () => { setLoading(true); setError(null); @@ -86,8 +95,104 @@ export default function CourseLtiToolsPage() { } }; + const confirmRotate = async () => { + if (!rotateModal) return; + setRotatingId(rotateModal.toolId); + setNewSecret(null); + setCopied(false); + try { + const result = await lmsApi.rotateCourseLtiToolSecret(id, rotateModal.toolId); + setNewSecret(result.new_secret); + } catch (e) { + setError(e instanceof Error ? e.message : "No se pudo rotar el secreto"); + setRotateModal(null); + } finally { + setRotatingId(null); + } + }; + + const closeRotateModal = () => { + setRotateModal(null); + setNewSecret(null); + setCopied(false); + }; + + const copySecret = () => { + if (!newSecret) return; + void navigator.clipboard.writeText(newSecret); + setCopied(true); + setTimeout(() => setCopied(false), 2500); + }; + return ( + {/* Modal de rotación de secreto */} + {rotateModal && ( +
+
+

+ + Rotar secreto — {rotateModal.toolName} +

+ + {!newSecret ? ( + <> +

+ Se generará un nuevo secreto aleatorio de 32 caracteres. El secreto actual dejará de funcionar inmediatamente. Actualiza tu herramienta LTI antes de confirmar. +

+
+ + +
+ + ) : ( + <> +
+

¡Secreto rotado exitosamente!

+

+ Copia este secreto ahora. No se volverá a mostrar. +

+
+ + {newSecret} + + +
+
+
+ +
+ + )} +
+
+ )}
@@ -171,6 +276,13 @@ export default function CourseLtiToolsPage() {
+