diff --git a/.cargo/2026-05-26_cargo_toml_source_sync_done.md b/.cargo/2026-05-26_cargo_toml_source_sync_done.md new file mode 100644 index 0000000..93d37f2 --- /dev/null +++ b/.cargo/2026-05-26_cargo_toml_source_sync_done.md @@ -0,0 +1,768 @@ +el tema con cargo xtask + tal y como lo tenemos @.coder/2026-05-26-deps-sync.info.md + es ¿ cómo actualizamos los crated a nuevas versiones sin romper el sync de dependencias ? + ⎿  Read .coder/2026-05-26-deps-sync.info.md (232 lines) + +⏺ Voy a verificar el estado actual del sistema xtask y la registry antes de responder. + + Searched for 1 pattern, listed 1 directory (ctrl+o to expand) + +⏺ ★ Insight ───────────────────────────────────── + - El sistema invierte el flujo habitual de Cargo: en vez de cargo update actualizando Cargo.lock, aquí los bumps de versión son + cambios source-controlled sobre registry/dependencies.toml, y el xtask los propaga determinísticamente a cada Cargo.toml. + - El overlay (rustelo-deps-overlay.toml) rechaza por diseño los overrides de versión — esto convierte cada bump en una decisión + global y atómica, no en una negociación per-impl. + - El --check reporta drift por conteo de líneas, no diff semántico: es un gate de "estado convergido", no un explicador del cambio. + ───────────────────────────────────────────────── + + El flujo para actualizar versiones + + El sistema no impide actualizar — al contrario, lo canaliza por un único punto. La pregunta correcta no es "cómo actualizo sin + romper el sync", sino "el sync convierte el bump en una decisión global; ¿estoy listo a propagar?". + + Workflow concreto + + 1. Editar registry/dependencies.toml ← única fuente de verdad + [deps.NAME] + version = "X.Y.Z" + + 2. cargo xtask sync-deps ← propaga a rustelo/Cargo.toml + 3. cargo xtask sync-deps --target ../jpl-website + (y a cada impl conocida) + + 4. cargo build / cargo test ← validar en CADA workspace + (rustelo + cada impl) + + 5. Si rompe en impl: + a) FORWARD → adaptar código del impl al nuevo API + b) REGRESS → bajar la versión en registry/dependencies.toml + y volver a sincronizar + + Lo que no se puede hacer (y es intencional) + + ┌─────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────┐ + │ Intento │ Por qué falla │ + ├─────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────┤ + │ [overlay.sqlx] version = "..." en impl │ El validador rechaza override de versión (línea 90-93 del │ + │ │ .info) │ + ├─────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────┤ + │ Editar a mano dentro del bloque # >>> │ El siguiente --check lo detecta como drift y sync-deps lo │ + │ rustelo-sync:workspace-deps │ sobreescribe │ + ├─────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────┤ + │ Per-impl version pin │ "Per-impl version overrides are intentionally NOT supported" │ + │ │ (línea 189) │ + └─────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────┘ + + Esto es el corazón del invariante de unificación cross-workspace: si dos workspaces pueden tener versiones distintas, no hay + garantía de unificación; el sistema entero pierde su razón de existir. + + Qué hacer con bumps que rompen impl + + El caso ya documentado: sqlx 0.8 → 0.9 en jpl-website tras un sync. + + ★ Insight ───────────────────────────────────── + - La "convergencia forzada" no es un bug sino el contrato. El xtask te empuja a tomar la decisión arquitectónica: el registry + refleja lo que el framework dice que es canónico, y los impls deben alinearse o el registry retrocede. + - La opción "Regress registry" es legítima pero costosa: significa que el framework no puede avanzar hasta que el impl bloqueante + se adapte. Útil como freno cuando hay deadline en impl. + - No existe punto medio. Si necesitas el punto medio, el sistema te está diciendo que el registry está mal-versionado: + probablemente la nueva versión no debió subir al registry todavía. + ───────────────────────────────────────────────── + + Patrón recomendado para bumps frágiles (leptos, axum, sqlx) + + 1. Crear branch en rustelo: bump/leptos-0.X + 2. Editar registry/dependencies.toml + 3. cargo xtask sync-deps # rustelo + 4. cargo build --workspace # ¿compila rustelo? + 5. cargo xtask sync-deps --target ../jpl-website + 6. cd ../jpl-website && cargo build # ¿compila impl? + 7. Si 4 o 6 falla: + - Fix forward → commit a rustelo (si es framework code) + → commit a impl (si es código de impl) + - O regress registry y volver al paso 2 + 8. Commit registry + Cargo.toml sincronizados JUNTOS (atomicidad de bump) + + El paso 8 es importante: el pre-commit hook (rustelo-deps-sync con --check) bloquea cualquier commit donde + registry/dependencies.toml esté desincronizado de Cargo.toml. Esto fuerza que el bump y su propagación sean un solo commit, no dos. + + Gap actual del sistema (línea 217 del .info) + + ▎ Auto-discovery of impls via rustelo.manifest.toml [sync] section (currently impls are reached via explicit --target). + + Hoy debes recordar a mano qué impls existen y pasarles --target uno a uno. Esto es un riesgo operacional real: si añades un impl + nuevo y olvidas sincronizarlo, drifta silenciosamente hasta que alguien corra el hook en ese repo. Si quieres, esto sería el + siguiente xtask útil de implementar (sync-deps --all leyendo de un manifiesto). + + ¿Quieres que profundice en alguno de los flujos — por ejemplo el patrón forward vs regress para un bump concreto que tengas + pendiente — o que mire el código del xtask para confirmar el comportamiento de algún caso edge? + +✻ Churned for 55s + +❯ zed nos muestra versiones actualizables sobre Cargo.toml pero es sobreescrito por xtask + +⏺ ★ Insight ───────────────────────────────────── + - Zed (vía rust-analyzer + crates lsp) detecta updates leyendo tablas [dependencies] o [workspace.dependencies] en archivos + Cargo.toml. No reconoce registry/dependencies.toml porque no es un manifiesto Cargo válido — es un schema ad-hoc del xtask. + - El conflicto es estructural: el sistema xtask deliberadamente coloca la fuente de verdad fuera del sitio donde las herramientas + Cargo-native esperan encontrarla. Esto es bueno para el invariante cross-workspace pero rompe el flujo editor-driven que ofrecen + Zed/rust-analyzer/crates-tooling. + ───────────────────────────────────────────────── + + Esto es una fricción real, no un mal uso. Tienes tres caminos. Te los presento con los tradeoffs. + + Opción A — Reverse-sync (ingest) + + Añadir un modo al xtask que lee Cargo.toml y actualiza registry/dependencies.toml (dirección inversa): + + 1. En Zed, click en el upgrade inline sobre Cargo.toml + 2. cargo xtask sync-deps --ingest ← lee Cargo.toml → escribe registry + 3. cargo xtask sync-deps --target ../jpl-website + 4. Commit los tres archivos juntos + + Pros: mínimo cambio estructural, conserva el schema actual del registry (con rebase, etc). + Contras: dos comandos por bump, fácil olvidar el --ingest y commitear Cargo.toml drifteado → el pre-commit hook lo detecta pero + solo si está activo. + + Opción B — Convertir el registry en Cargo.toml ficticio (recomendada) + + Reemplazar registry/dependencies.toml por registry/Cargo.toml: + + # registry/Cargo.toml + [workspace] + members = [] + exclude = [] + + [workspace.dependencies] + leptos = { version = "0.8" } + axum = { version = "0.8" } + sqlx = { version = "0.9", features = ["postgres"], default-features = false } + rustelo_utils = { path = "../crates/foundation/crates/rustelo_utils" } + # ... + + Y en rustelo/Cargo.toml: + exclude = ["xtask", "registry"] + + Workflow: + 1. Abrir registry/Cargo.toml en Zed + 2. Click inline-upgrade — Zed actualiza directamente la fuente de verdad + 3. cargo xtask sync-deps ← propaga a rustelo/Cargo.toml + 4. cargo xtask sync-deps --target ../jpl-website + + ★ Insight ───────────────────────────────────── + - Truco clave: registry/Cargo.toml con members = [] es un workspace válido pero vacío — Cargo no compila nada ahí, pero + rust-analyzer sí parsea sus deps y Zed muestra los upgrades disponibles. + - Las paths necesitan re-prefix con ../ (porque la base cambia), pero el xtask ya tiene la lógica de pathdiff para rebase, así que + solo invierte el cálculo en una dirección. + - La metadata rebase = false no cabe en el formato Cargo nativo. Solución: moverla a un sidecar registry/sync.toml o inferirla + (cualquier path que sale del workspace = no-rebase por defecto). + ───────────────────────────────────────────────── + + Pros: Zed funciona nativamente, sin pasos extra. Bump y sync siguen siendo dos operaciones explícitas pero la primera ya no + requiere herramienta especial. + Contras: migración del schema, hay que decidir dónde vive rebase (sidecar o convención). Cargo carga un workspace extra en cada + operación (impacto mínimo). + + Opción C — Aceptar la fricción + + Documentar que Zed no debe usarse para upgrades en Cargo.toml, solo para descubrir versiones disponibles. El bump real se hace a + mano en registry/dependencies.toml. Cero código nuevo, máxima fricción humana. + + Recomendación + + Opción B. Es la única que elimina la fricción en lugar de añadirle un workaround. El xtask sigue siendo necesario (para el splice + cross-workspace), pero deja de ser el único punto de entrada — Zed se convierte en una interfaz válida porque la fuente de verdad + ahora habla el dialecto que Zed entiende. + + La migración es mecánica: + 1. Renombrar registry/dependencies.toml → registry/Cargo.toml con schema [workspace.dependencies] + 2. Re-prefix de paths con ../ + 3. Mover rebase a registry/sync.toml (o inferirlo) + 4. Adaptar xtask/src/registry.rs para leer el nuevo formato + 5. Añadir exclude = ["registry"] a rustelo/Cargo.toml + 6. Validar cargo xtask sync-deps --check con la nueva fuente + + ¿Quieres que lo implemente, o prefieres ver primero el código actual de xtask/src/registry.rs para evaluar el alcance del cambio? + +✻ Baked for 1m 1s + +※ recap: Goal: resolve friction where Zed shows Cargo.toml upgrades but xtask overwrites them. Current task: choose between + reverse-sync (ingest), converting registry to a fake Cargo.toml (recommended), or accepting the friction. Next: decide which option + to implement. (disable recaps in /config) +por qué implementar la opción B es más recomentable que A ? + +⏺ ★ Insight ───────────────────────────────────── + - La pregunta no es trivial: A es menos invasiva (no migra schema, no toca paths) y resuelve el síntoma. B requiere migración. Si + solo miráramos esfuerzo, A gana. La elección depende de qué propiedad valoramos más: minimizar el cambio (A) o minimizar la + complejidad sistémica resultante (B). + ───────────────────────────────────────────────── + + Te lo desgloso en ejes concretos. Voy del más decisivo al menos. + + 1. Direccionalidad del flujo + + A introduce bidirección. El sync deja de ser una operación push (registry → Cargo.toml) y pasa a tener dos modos: push y pull. + Bidirectional syncs son históricamente fuente de bugs sutiles — son el problema clásico de réplica master-master. + + B preserva la unidireccionalidad. Siempre registry/Cargo.toml → todos los demás. El xtask no necesita resolver conflictos porque no + puede haberlos: solo hay un origen. + + 2. Idempotencia y orden de operaciones + + A pierde idempotencia parcial. El resultado de cargo xtask sync-deps depende del estado relativo de dos archivos: si Cargo.toml + tiene un upgrade que registry no, el sync lo borra. Esto significa que el orden importa: primero --ingest, después sync-deps. + Olvidar el orden = trabajo perdido silenciosamente. + + B es idempotente. Corre sync-deps N veces sobre el mismo registry: resultado idéntico. El estado en disco de Cargo.toml es + irrelevante para el resultado, porque siempre es destino, nunca origen. + + 3. Coherencia con el modelo de "fuente de verdad" + + ★ Insight ───────────────────────────────────── + - El diseño actual ya tiene "DO NOT EDIT" en los marcadores del bloque managed. Es una declaración explícita: este texto es + derivado, no lo toques. La opción A contradice esa declaración: pide al usuario que edite el bloque managed (vía Zed) como si fuera + fuente, y luego ingiera el cambio. Confunde el contrato. + ───────────────────────────────────────────────── + + A rompe el invariante semántico del marker # >>> rustelo-sync:workspace-deps (managed by xtask — DO NOT EDIT). Con --ingest, el + bloque managed pasa a ser editable bajo ciertas condiciones. El marker miente. + + B preserva el invariante. El bloque managed sigue siendo solo-escritura-por-xtask. La edición humana ocurre en otro archivo, + claramente identificado como fuente. + + 4. Composabilidad con ecosistema Cargo + + Esto es el argumento más fuerte a medio plazo. + + A solo resuelve Zed. ¿Y cargo upgrade de cargo-edit? ¿Y Dependabot, que mira Cargo.toml? ¿Y cargo outdated? Cada herramienta que en + el futuro quieras integrar requerirá su propio adaptador --ingest-from-X, o un paso manual. + + B resuelve toda la familia de una sola vez. Cualquier herramienta que sepa leer [workspace.dependencies] (es decir, todas las + herramientas Cargo-native) opera sobre la fuente sin saber que existe el sync. + +```text + A B + │ │ + ┌────────┼────────┐ ┌────────┼────────┐ + │ │ │ │ │ │ + Zed cargo-up Depbot Zed cargo-up Depbot + │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ + Cargo (custom) (custom) registry/Cargo.toml + │ │ │ │ + └───────┼────────┘ ▼ + ▼ xtask sync + xtask --ingest + ▼ + registry + ▼ + xtask sync +``` + + En A cada herramienta nueva requiere un adaptador o un paso manual. En B la herramienta apunta directamente a la fuente. + + 5. Superficie de código en el xtask + + A añade código: parser inverso (Cargo.toml → dep map), detección de conflicto (¿qué pasa si registry y Cargo.toml difieren en N + deps a la vez?), validación de overlay contra ingest, lógica de "qué dirección priorizar". + + B reduce código: desaparece el parser custom de dependencies.toml, se reemplaza por el parser de Cargo.toml que el xtask ya tiene + (o toml_edit directo). El schema custom se va. + + 6. Modo de fallo y recuperación + + ┌───────────────────────────┬───────────────────────────────────────────────────────┬──────────────────────────────────────────┐ + │ Escenario │ A │ B │ + ├───────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────────────────────────┤ + │ Editor con upgrade inline │ flujo natural pero peligroso (olvido de --ingest) │ flujo natural y seguro │ + ├───────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────────────────────────┤ + │ Edit manual en archivo │ difícil distinguir error de uso correcto (Cargo.toml │ error claro: editaste un bloque marcado │ + │ equivocado │ es editable a veces) │ "DO NOT EDIT" │ + ├───────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────────────────────────┤ + │ Dos personas suben bumps │ merge complejo: ambos editaron Cargo.toml + ambos │ merge normal en registry/Cargo.toml, │ + │ distintos en paralelo │ --ingest-aron, registry tiene un orden ganador │ conflicto de Git visible │ + │ │ silencioso │ │ + ├───────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────────────────────────┤ + │ Pre-commit detecta drift │ sí, pero el mensaje confunde (¿debía hacer --ingest?) │ sí, mensaje claro: "registry y │ + │ │ │ Cargo.toml difieren, corre sync-deps" │ + └───────────────────────────┴───────────────────────────────────────────────────────┴──────────────────────────────────────────┘ + + 7. Curva de aprendizaje para nuevos contribuidores + + A: dos archivos parecidos (Cargo.toml y dependencies.toml con schema custom), una operación que depende de la dirección. Necesita + documentación y disciplina humana. + + B: un archivo (registry/Cargo.toml) que cualquier rustáceo entiende a primera vista, un comando para propagar (sync-deps). La + documentación se reduce a "el registry es un workspace Cargo vacío cuya única función es declarar dependencies". + + El caso donde A gana + + Hay uno: si los rebase/features/metadata custom del schema actual son suficientemente ricos para que migrarlos a formato Cargo + nativo pierda información, A preserva la flexibilidad. Mirando el schema actual: + + [deps.NAME] + version = "..." + path = "..." + features = ["..."] + default_features = false + rebase = false + + Solo rebase no tiene correspondencia directa en formato Cargo. El resto (version, path, features, default-features) es exactamente + la sintaxis de Cargo workspace.dependencies. Así que la "pérdida" se limita a un campo, que se resuelve con un sidecar + registry/sync.toml muy pequeño: + + [paths] + "platform-nats" = { rebase = false } + + ★ Insight ───────────────────────────────────── + - En el fondo, A trata el síntoma ("Zed no ve el registry"); B trata la causa ("la fuente de verdad usa un dialecto que el + ecosistema no entiende"). La cura siempre es preferible al alivio cuando el coste de la cura es proporcional al problema — y aquí + lo es: una migración mecánica de un archivo a otro formato. + ───────────────────────────────────────────────── + + Resumen ejecutivo + + A resuelve Zed con un comando nuevo, manteniendo un schema custom y añadiendo bidireccionalidad. B resuelve Zed (y cualquier futura + herramienta Cargo-native) eliminando el schema custom y manteniendo la unidireccionalidad del sync. El coste es una migración + única; el beneficio es un sistema más simple y más componible para siempre. + + A es la respuesta correcta si la migración es prohibitiva o si hay restricciones que aún no me has contado (p.ej. impls que ya usan + el formato dependencies.toml directamente). B es la respuesta correcta en cualquier otro escenario. + + ¿Hay alguna restricción del lado de jpl-website u otros impls que me esté perdiendo y que pudiera hacer la migración más costosa de + lo que asumo? + +✻ Brewed for 1m 12s + +❯ implementa la opción B + + ★ Insight ───────────────────────────────────── + - El sync reproduce bit-a-bit la región managed previa — confirma que el cambio de schema preserva la semántica. Para platform-nats + (no-rebase) el path se canonicaliza a ../stratumiops/crates/platform-nats igual que antes, pero ahora viene de + ../../stratumiops/... en registry/Cargo.toml (registry-relative). + - pathdiff opera textualmente sobre componentes, así que canonicalize antes del diff evita que .. segmentos contaminen el output. + Sin canonicalize, el path emitido sería registry/../crates/... — válido pero feo. Con canonicalize, es limpio. + - [workspace.dependencies] con members = [] es un workspace Cargo legítimo: cargo metadata retorna packages: [] sin error. Cargo + solo valida sintaxis del manifest, no intenta resolver paths (no hay miembros que las usen). Esto es justo lo que necesitamos: + Zed/rust-analyzer escanean la tabla, Cargo no compila nada. + ───────────────────────────────────────────────── + + GUIDELINE: rust.md (no unwrap, idiomatic, ?, expect, clippy -D warnings clean) + IMPLEMENTING: opción B — registry como workspace Cargo vacío editable nativamente por Zed + EXCLUDED: documentación nueva, commits, actualización de .coder/2026-05-26-deps-sync.info.md + (queda stale; pendiente de tu confirmación para refrescarlo) + + Cambios aplicados + + ┌────────────────────────────┬───────────────┬─────────────────────────────────────────────────────────────────────────────────┐ + │ Archivo │ Acción │ Por qué │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ registry/Cargo.toml │ nuevo │ Workspace Cargo vacío con [workspace.dependencies] — fuente de verdad │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ registry/sync.toml │ nuevo │ Sidecar: no_rebase = ["platform-nats"] (única metadata sin equivalente Cargo) │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ registry/dependencies.toml │ borrado │ Schema custom reemplazado │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ xtask/src/registry.rs │ reescrito │ Parsea Cargo.toml (untagged enum string-or-table) + lee sidecar; Dep con │ + │ │ │ Deserialize manual │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ xtask/src/render.rs │ reescrito │ Rebase con origen en registry/, canonicalize antes de diff_paths, propaga Result │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ xtask/src/sync.rs │ edición │ Propaga ? desde render_region; fix clippy pendiente │ + │ │ mínima │ (manual_pattern_char_comparison) │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ xtask/src/overlay.rs │ edición │ Mensaje de error apunta a registry/Cargo.toml │ + │ │ mínima │ │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ xtask/src/main.rs │ edición │ Comentarios docs actualizados │ + │ │ mínima │ │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ Cargo.toml (raíz) │ edición │ exclude = ["xtask", "registry"] para que la sub-workspace no contamine │ + ├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤ + │ .pre-commit-config.yaml │ edición │ Regex ^(Cargo\.toml|registry/(Cargo|sync)\.toml)$ │ + └────────────────────────────┴──────────────┴──────────────────────────────────────────────────────────────────────────────────┘ + + Verificación ejecutada + + $ cargo build --manifest-path xtask/Cargo.toml → OK + $ cargo clippy --manifest-path xtask/Cargo.toml -D warnings → OK + $ cargo xtask sync-deps --check → "up to date" (exit 0) + $ cargo metadata --no-deps --manifest-path Cargo.toml → OK + $ cargo metadata --no-deps --manifest-path registry/Cargo.toml → OK (packages=[]) + + La región managed en rustelo/Cargo.toml quedó idéntica al estado previo a la migración — convergencia bit-a-bit valida que la + traducción del schema preserva semántica. + + Flujo nuevo para Zed + + 1. Abrir registry/Cargo.toml en Zed → rust-analyzer detecta [workspace.dependencies] y muestra upgrades inline. + 2. Click en el upgrade → Zed escribe directamente en la fuente de verdad. + 3. cargo xtask sync-deps → propaga a rustelo/Cargo.toml. + 4. cargo xtask sync-deps --target ../jpl-website → propaga a cada impl. + 5. Pre-commit hook bloquea commits desincronizados (fires en cambios a Cargo.toml, registry/Cargo.toml o registry/sync.toml). + + Pendientes (no implementados, por si los quieres) + + - Actualizar .coder/2026-05-26-deps-sync.info.md: el doc describe el formato viejo (dependencies.toml, schema custom). Su modelo + conceptual sigue siendo correcto pero los detalles concretos están stale. + - Sincronizar jpl-website u otros impls: no he tocado nada fuera de rustelo. Cuando ejecutes cargo xtask sync-deps --target + ../jpl-website la primera vez producirá output equivalente al anterior (la región managed allí seguirá la misma forma). + - Auto-discovery de impls: sigue pendiente del .info original; no es regresión sino limitación heredada. + + STATUS: Complete + +⏺ ★ Insight ───────────────────────────────────── + - En rustelo las recetas viven en el top-level (justfiles/build.just importado con import), así que se invocan planas (just + deps-sync). En jpl-website el justfile usa mod build "just/build.just", así que se invocan con namespace (just build deps-sync). + Misma funcionalidad, dos convenciones distintas según el patrón del proyecto. + - Para el impl, la receta encapsula tres detalles operacionales molestos: la ruta al xtask (../rustelo/xtask/Cargo.toml), las flags + --rustelo-root ../rustelo --target ., y el override de CARGO_TARGET_DIR para no contaminar el target volume-pinned del framework. + El humano nunca tiene que recordarlos. + ───────────────────────────────────────────────── + + Resumen de cambios + + Documentación + - .coder/2026-05-26-deps-sync.info.md — actualizado: schema nuevo (registry/Cargo.toml + sync.toml), workflow editor-driven con + Zed, rationale de la migración, verificación de bit-identical reproduction. + - rustelo/reflection/qa.ncl — deps-sync-workflow reescrito: bump editor-driven como flujo recomendado, schema Cargo-native, sidecar + no_rebase, sección "why a fake Cargo.toml". + - jpl-website/reflection/qa.ncl — deps-sync-impl-workflow actualizado: referencia a ../rustelo/registry/Cargo.toml, mención de Zed + inline upgrades para descubrir versiones disponibles. + + Recetas just + │ rustelo │ just deps-sync │ propagar registry → rustelo/Cargo.toml │ + ├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤ + │ rustelo │ just deps-sync-check │ verificar drift sin escribir │ + ├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤ + │ rustelo │ just deps-sync-impl ../jpl-website │ propagar a impl arbitrario │ + ├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤ + │ rustelo │ just deps-sync-check-impl ../jpl-website │ verificar drift en impl │ + ├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤ + │ rustelo │ just deps-sync-all │ rustelo + impls conocidos en un solo comando │ + ├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤ + │ jpl-website │ just build deps-sync │ pull desde ../rustelo/registry/Cargo.toml │ + ├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤ + │ jpl-website │ just build deps-sync-check │ verificar drift contra rustelo │ + └─────────────┴──────────────────────────────────────────┴──────────────────────────────────────────────┘ + + Verificado + $ just deps-sync-check → ./Cargo.toml is up to date (exit 0) + $ just --list (rustelo) → 5 recipes deps-sync* visibles + $ just --list build (jpl) → deps-sync, deps-sync-check visibles bajo build:: + + STATUS: Complete + +escribe un doc en .coder + para pedir en una sesión claude code + que se implemente este mismo mecanismo tal cual (opción B e incluir las recetas just) de sync de crates en un proyecto nuevo + + +⏺ ★ Insight ───────────────────────────────────── + - El doc está estructurado como prompt para una sesión Claude Code, no como referencia general. Cada task usa el formato + obligatorio del proyecto (COMPLEXITY/DEPENDENCIES/RISKS/SUCCESS_CRITERIA) sin estimaciones temporales, lo que evita que la sesión + receptora "improvise" pasos. + - Las tasks marcan dependencias explícitas (1→3→4→5→6→[7,8,9]→10), así que si la sesión receptora se interrumpe a la mitad, puede + retomar desde un estado parcial seguro sin reconstruir contexto. + - El doc separa lo que se debe copiar tal cual (xtask source, markers # >>> rustelo-sync) de lo que hay que adaptar (paths + registry-relativos, lista de deps, hook id). Esa separación es lo que distingue una replicación correcta de una "reescritura + libre". + ───────────────────────────────────────────────── + + Resumen del doc + + Ruta: .coder/2026-05-26-deps-sync-replication.plan.md + + Es un brief auto-contenido para entregar a otra sesión Claude Code. Estructura: + + 1. Why / Reference implementation — contexto mínimo + apuntadores a la doc canónica de rustelo + 2. Preconditions — qué debe tener el proyecto destino + 3. Open questions — 5 preguntas que la sesión receptora debe hacer al usuario antes de empezar (project root, project name, + framework-vs-impl, no-rebase deps, downstream impls) + 4. 10 tasks ordenadas con COMPLEXITY/DEPENDENCIES/RISKS/SUCCESS_CRITERIA cada una: + - 1: crear registry/Cargo.toml + - 2: crear registry/sync.toml + - 3: copiar xtask + - 4: editar root Cargo.toml (exclude) + - 5: añadir alias .cargo/config.toml + - 6: primer sync + commit + - 7: pre-commit hook + - 8: recetas just (framework) + - 9: recetas just (impl) — opcional + - 10: validación end-to-end + 5. Adaptations checklist — qué cambiar vs rustelo (paths, nombre, lista de deps, etc.) + 6. Things to leave untouched — marker text, standalone-workspace de xtask, deserialización dual de Dep + 7. Order of independence — qué subconjuntos de tasks son estados estables si se quiere parar a mitad + 8. See also — los tres docs/QA de referencia en el repo rustelo + + El doc asume que rustelo está accesible para cp -r ../rustelo/xtask .; si no lo está, la sesión receptora debe preguntar dónde vive + (mencionado explícitamente en la Open question #1 y Task 3 NOTES). + + STATUS: Complete + + exlica lo de Auto-discovery de impls: sigue pendiente del .info original; no es regresión sino limitación heredada. + +⏺ ★ Insight ───────────────────────────────────── + - "Limitación heredada" significa que existía en el diseño original con dependencies.toml y la migración a opción B no la introdujo + ni la resolvió. Decir "no es regresión" es importante: si el usuario migrara con la expectativa de que B arreglase TODO lo del + .info original, este punto le decepcionaría sin esta aclaración. + ───────────────────────────────────────────────── + + Qué significa "auto-discovery de impls" + + Hoy, cuando ejecutas cargo xtask sync-deps, el xtask sincroniza un solo target — el que recibe por CLI. Hay tres modos posibles: + + cargo xtask sync-deps → rustelo/Cargo.toml + cargo xtask sync-deps --target ../jpl-website → jpl-website/Cargo.toml + cargo xtask sync-deps --target ../otra-impl → otra-impl/Cargo.toml + + El xtask no sabe qué impls existen en el ecosistema. Si añades ../proyecto-z mañana como consumidor del registry, el xtask no se + entera hasta que alguien le pase --target ../proyecto-z explícitamente. + + Auto-discovery sería el mecanismo opuesto: el xtask conoce la lista completa de impls y puede iterar sobre ellos sin que un humano + se la recuerde: + + cargo xtask sync-deps --all + → sincroniza rustelo + cada impl declarada en algún lugar + + Por qué importa operacionalmente + + Hoy hay tres puntos de fricción: + + 1. Memoria humana: para mantener el ecosistema en sync, alguien debe recordar la lista de impls. En mi receta deps-sync-all añadí + ../jpl-website hardcoded — si mañana aparece ../jpl-blog, hay que editar la receta. Y nadie lo recordará hasta que rompa algo. + 2. Drift silencioso: si una impl nueva no se añade a la lista, los pre-commit hooks de cada repo seguirán fallando + independientemente (eso protege la integridad de cada repo aisladamente), pero no hay un sitio donde rustelo te diga "atención, hay + 3 impls drifteadas". Cada repo es una isla hasta que alguien hace commit allí. + 3. Onboarding: un colaborador nuevo en rustelo no sabe automáticamente qué impls hay que sincronizar tras un bump de versión. Tiene + que preguntar o leer el README/justfile. + + Por qué no es regresión de la migración a opción B + + El diseño original (registry/dependencies.toml schema custom) tampoco tenía auto-discovery. El .info original lo lista en su + sección Open items: + + - Auto-discovery of impls via `rustelo.manifest.toml` `[sync]` section + (currently impls are reached via explicit `--target`). + + La migración a opción B preservó todo el comportamiento del CLI: --target sigue funcionando idéntico, los markers son los mismos, + el splice es el mismo. Solo cambió el formato de la fuente de verdad, no la topología del sync. Por eso uso "limitación heredada" — + viaja con el sistema desde el diseño original. + + ★ Insight ───────────────────────────────────── + - En realidad la migración a opción B hace este open item más fácil de resolver, no más difícil. Antes había que extender el schema + custom de dependencies.toml ([meta], [deps.X], y ahora añadir [sync.impls]). Con opción B, ese tipo de metadata no-Cargo ya tiene + un sitio natural: registry/sync.toml. Solo hay que añadir un campo y una iteración en sync.rs. + ───────────────────────────────────────────────── + + Cómo se resolvería (boceto, no implementado) + + Extender registry/sync.toml: + + no_rebase = ["platform-nats"] + + [impls] + known = [ + "../jpl-website", + "../jpl-blog", # ejemplo futuro + ] + + En el xtask, añadir un flag --all: + + // sync.rs (boceto) + if args.all { + let sidecar = registry::load_sidecar(&args.rustelo_root)?; + // sync rustelo + sync_target(&args.rustelo_root, /*target=*/ &args.rustelo_root, …)?; + for impl_path in sidecar.impls.known { + let target = args.rustelo_root.join(&impl_path); + sync_target(&args.rustelo_root, &target, …)?; + } + } + + Y la receta just simplificaría: + + # antes + deps-sync-all: + @just deps-sync + @just deps-sync-impl ../jpl-website # hardcoded + + # después + deps-sync-all: + cargo xtask sync-deps --all # lista vive en sync.toml + + Por qué sigue pendiente + + Es un trade-off consciente, no un olvido: + + ┌───────────────────────────────────────┬──────────────────────────────────────────────────────────────┐ + │ Argumento a favor de implementarlo │ Argumento a favor de aplazarlo │ + ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤ + │ Elimina memoria humana del flujo │ Hoy solo hay 1 impl (jpl-website); sobre-ingeniería para N=1 │ + ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤ + │ Drift detection centralizado │ Cada impl ya tiene su pre-commit hook independiente │ + ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤ + │ Onboarding más simple │ El README/justfile ya documenta el comando manual │ + ├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤ + │ sync.toml ya tiene sitio para meterlo │ Es código nuevo en xtask, validación, tests │ + └───────────────────────────────────────┴──────────────────────────────────────────────────────────────┘ + + El veredicto del .info original fue "no urgente, pero está identificado". Coincido. Si alguna vez aparece la segunda impl, vale la + pena cerrarlo; con una sola impl, el --target manual es perfectamente operativo. + + Resumen en una línea + + ▎ Auto-discovery = el xtask conoce la lista de impls que consumen el registry y puede sincronizarlas en bloque sin que un humano se + ▎ las recuerde. Sigue pendiente porque con una sola impl no compensa el código adicional, pero la opción B (sidecar sync.toml) + ▎ deja el camino más despejado para resolverlo el día que haga falta. + +✻ Churned for 59s + +❯ implementalo, en cuanto verifiquemos jpl-website vamos a crear más sites + + Plan en orden de dependencias: + + TASK 1: Extender sidecar con [impls.known] en registry.rs + COMPLEXITY: simple + DEPENDENCIES: none + RISKS: typos en sync.toml apuntan a paths que no existen → mitigación: validar existencia al cargar + SUCCESS_CRITERIA: load_sidecar() retorna no_rebase + impls + + TASK 2: Refactorizar sync.rs para extraer sync_one() y añadir driver --all + COMPLEXITY: simple + DEPENDENCIES: TASK 1 + RISKS: en --check --all, error en target N no debe abortar el chequeo de N+1 + SUCCESS_CRITERIA: sync_one por target, drift de cualquier target → exit 1 al final + + TASK 3: Añadir flag --all en CLI (conflicts_with --target y --overlay) + COMPLEXITY: trivial + DEPENDENCIES: TASK 2 + SUCCESS_CRITERIA: --all sin --target ni --overlay funciona; combinarlos error + + TASK 4: Declarar ../jpl-website en registry/sync.toml [impls.known] + COMPLEXITY: trivial + DEPENDENCIES: TASK 3 + SUCCESS_CRITERIA: cargo xtask sync-deps --all itera 2 targets + + TASK 5: Sustituir deps-sync-all en justfile + añadir deps-sync-check-all + COMPLEXITY: trivial + DEPENDENCIES: TASK 4 + SUCCESS_CRITERIA: just deps-sync-check-all exit 0 + + TASK 6: Documentación (info, QA, replication plan) + COMPLEXITY: simple + DEPENDENCIES: TASK 5 + SUCCESS_CRITERIA: auto-discovery sale de Open Items y aparece en Architecture + + Empiezo. + + +⏺ ★ Insight ───────────────────────────────────── + + - El refactor importante fue normalizar paths registry-relative → + rustelo-root-relative en el loader, en lugar de cambiar la lógica del render. + Eso preservó el contrato de Rebase::translate_path y evitó romper el + rustelo-root-relativos, no registry-relativos). Una capa de traducción + centralizada en un solo punto = un solo sitio que mantener. + - sync_one retorna un enum SyncOutcome en vez de un ExitCode. Eso permite al + driver de --all agregar drift de N targets sin perder información: cada target + imprime su outcome, y solo al final el run agregado decide el código de + salida. Si hubiera usado ExitCode, habrías tenido que return-early en el + primer drift. + - Adding una impl ahora es exactamente una línea en registry/sync.toml. No + xtask, no recetas. Esa es la propiedad clave para tu plan de crear más sites: + la fricción operacional al escalar de N=1 a N=K se mantiene constante (O(1) + por impl). + ───────────────────────────────────────────────── + + GUIDELINE: rust.md (no unwrap, idiomatic, ?, expect, clippy -D warnings clean) + IMPLEMENTING: auto-discovery de impls vía [impls.known] en sync.toml + --all + en xtask + EXCLUDED: ondaod evaluation (sin solicitud de adr?), commits + + Cambios + + ┌───────────────────────┬──────────────────────────────────────────────────┐ + │ Archivo │ Acción │ + ├───────────────────────┼──────────────────────────────────────────────────┤ + │ xtask/src/registry.rs │ Sidecar struct, load_sidecar() público, │ + │ │ normalize_registry_path() (strip ..) │ + ├───────────────────────┼──────────────────────────────────────────────────┤ + │ xtask/src/sync.rs │ refactor en sync_one() + driver de --all; agrega │ + │ xtask/src/sync.rs │ refactor en sync_one() + driver de │ + │ │ --all; agrega drift sin fail-fast │ + ├─────────────────────────────────────┼────────────────────────────────────┤ + │ │ revert al contrato original │ + │ xtask/src/render.rs │ (Dep.path rustelo-root-relative, │ + │ │ sin canonicalize) │ + ├─────────────────────────────────────┼────────────────────────────────────┤ + │ xtask/src/main.rs │ flag --all, --target ahora │ + │ │ Option │ + ├─────────────────────────────────────┼────────────────────────────────────┤ + │ registry/sync.toml │ añadido [impls] known = │ + │ │ ["../jpl-website"] │ + ├─────────────────────────────────────┼────────────────────────────────────┤ + ──────────────────────────────────────── + Archivo: .coder/2026-05-26-deps-sync.info.md + Acción: [impls] documentado, --all documentado, auto-discovery movida de Open + → + Closed + ──────────────────────────────────────── + Archivo: reflection/qa.ncl (rustelo) + Acción: sección PROPAGATE TO IMPLEMENTATIONS actualizada con --all + ──────────────────────────────────────── + Archivo: .coder/2026-05-26-deps-sync-replication.plan.md + Acción: Task 2 y Task 8 actualizadas con [impls.known] y --all + + Verificación + + $ cargo xtask sync-deps --check → exit 0 + $ cargo xtask sync-deps --all --check → 2 targets, exit 0 + $ cargo xtask sync-deps --all --target X → rejected (exit 2) + $ cargo xtask sync-deps --all --overlay Y → rejected (exit 2) + $ cargo clippy --manifest-path xtask/Cargo.toml -- -D warnings → exit 0 + $ just deps-sync-check-all → 2 targets, exit 0 + + Para los próximos sites + + Cuando crees ../site-nueva, el workflow es: + + 1. Crear el workspace ../site-nueva con [workspace.dependencies] (vacío o + pobladio). + + Para los próximos sites + + Cuando crees ../site-nueva, el workflow es: + + 1. Crear el workspace ../site-nueva con [workspace.dependencies] (vacío o pobladio). + 2. Opcional: crear ../site-nueva/rustelo-deps-overlay.toml con [overlay.*] y [extra.*]. + 3. Editar registry/sync.toml: añadir "../site-nueva" al array [impls.known]. + 4. cargo xtask sync-deps --all — itera rustelo + jpl-website + site-nueva. + 5. (Opcional) Replicar el hook pre-commit en ../site-nueva/.pre-commit-config.yaml apuntando al xtask de rustelo. + + No hay cambios al xtask ni a las recetas just. La operación es O(1) por impl añadida. + + STATUS: Complete + + +⏺ Confirmado. El doc ahora refleja el modelo [impls.known] + --all en cuatro puntos: + + ┌──────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────┐ + │ Sección │ Contenido │ + ├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤ + │ Open questions (línea 67) │ Pregunta sobre impls indica que se encodean en registry/sync.toml [impls.known], no │ + │ │ en la receta │ + ├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤ + │ Task 2 RISKS (línea 104) │ Añade riesgo de impl path sin Cargo.toml │ + ├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤ + │ Task 2 SUCCESS_CRITERIA (líneas │ Schema de sync.toml incluye [impls] known = [...] │ + │ 113–114) │ │ + ├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤ + │ Task 2 NOTES (línea 121) │ Documenta que [impls.known] habilita --all y que añadir impls es one-line edit │ + ├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤ + │ Task 8 recipes (líneas 269–278) │ deps-sync-all y deps-sync-check-all usan cargo xtask sync-deps --all │ + ├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤ + │ Adaptations checklist (línea 354) │ Señala que la lista de impls va en sync.toml, no en la receta │ + └──────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────┘ + + STATUS: Complete diff --git a/Cargo.toml b/Cargo.toml index 3519274..d9dc33a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,173 +13,159 @@ members = [ "crates/foundation/crates/rustelo_core_types", "crates/foundation/crates/rustelo_language", "crates/foundation/crates/rustelo_routing", - "crates/foundation/crates/rustelo_components", - "crates/foundation/crates/rustelo_pages", + "crates/foundation/crates/rustelo_components_leptos", + "crates/foundation/crates/rustelo_components_htmx", + "crates/foundation/crates/rustelo_pages_htmx", + "crates/foundation/crates/rustelo_pages_leptos", + "crates/foundation/crates/rustelo_seo", "crates/foundation/crates/rustelo_tools", "crates/foundation/crates/rustelo_utils", "crates/foundation/crates/rustelo_macros", + "crates/foundation/crates/rustelo_config", + "crates/foundation/crates/rustelo_content_graph_ssr", + "examples/plugin-example-theme", ] +exclude = ["xtask", "registry", "features/content-graph"] [workspace.dependencies] -# Core dependencies - -# Rustelo foundation crates -rustelo_utils = { path = "crates/foundation/crates/rustelo_utils" } -rustelo_core_types = { path = "crates/foundation/crates/rustelo_core_types" } -rustelo_language = { path = "crates/foundation/crates/rustelo_language" } -rustelo_routing = { path = "crates/foundation/crates/rustelo_routing" } -rustelo_core_lib = { path = "crates/foundation/crates/rustelo_core_lib" } -rustelo_components = { path = "crates/foundation/crates/rustelo_components" } -rustelo_pages = { path = "crates/foundation/crates/rustelo_pages" } -rustelo_client = { path = "crates/foundation/crates/rustelo_client" } -rustelo_server = { path = "crates/foundation/crates/rustelo_server" } -rustelo_tools = { path = "crates/foundation/crates/rustelo_tools" } -rustelo_macros = { path = "crates/foundation/crates/rustelo_macros" } - -# Rustelo framework crates -rustelo_core = { path = "crates/framework/crates/rustelo_core" } -rustelo_web = { path = "crates/framework/crates/rustelo_web" } -rustelo_auth = { path = "crates/framework/crates/rustelo_auth" } -rustelo_content = { path = "crates/framework/crates/rustelo_content" } -rustelo_cli = { path = "crates/framework/crates/rustelo_cli" } - - -# Leptos ecosystem -leptos = { version = "0.8.15", features = ["hydrate", "ssr"] } -leptos_router = { version = "0.8.11", features = ["ssr"] } -leptos_axum = { version = "0.8.7" } -leptos_config = { version = "0.8.8" } -leptos_meta = { version = "0.8.5" } -leptos_integration_utils = { version = "0.8.7" } - -# Other dependencies -serde = { version = "1.0", features = ["derive"] } -axum = "0.8.8" -serde_json = "1.0" -thiserror = "2.0.18" -anyhow = "1.0.101" -rand = "0.9" - -rand_core = { version = "0.10" } -#rand_core = { version = "0.6", features = ["getrandom"] } -getrandom = { version = "0.4", features = ["std", "wasm_js"] } -gloo-timers = { version = "0.3", features = ["futures"] } -gloo-net = { version = "0.6.0" } -glob = "0.3.3" +# >>> rustelo-sync:workspace-deps (managed by `cargo xtask sync-deps` — DO NOT EDIT) +aes-gcm = "0.10" +ammonia = "4.1" +any_spawner = "0.3" +anyhow = "1.0.102" +argon2 = "0.5" +async-compression = { version = "0.4", features = ["gzip", "tokio"] } +async-nats = "0.49" +async-trait = "0.1.89" +axum = "0.8.9" +axum-server = { version = "0.8", features = ["tls-rustls"] } +axum-test = "20.0" +base32 = "0.5" +base64 = "0.22" +bytes = "1.11" +cfg-if = "1.0" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4.6", features = ["derive", "env"] } +comrak = { version = "0.52", features = ["syntect"] } +console = "0.16" console_error_panic_hook = "0.1.7" -http = "1" -log = "0.4.29" -env_logger = "0.11" -wasm-bindgen-futures = "0.4.58" -wasm-bindgen = "0.2.108" -serde-wasm-bindgen = "0.6.5" console_log = "1" -reqwest = { version = "0.13.2", features = ["json"] } # reqwest with JSON parsing support -reqwasm = "0.5.0" -js-sys = "0.3.85" -web-sys = { version = "0.3.85" , features = ["Clipboard", "Window", "Navigator", "Permissions", "MouseEvent", "Storage", "console", "File", "SvgElement", "SvgsvgElement", "SvgPathElement", "MediaQueryList"] } -regex = "1.12.3" -tracing = "0.1" -tracing-subscriber = "0.3" -toml = "0.9" +crossterm = "0.29" +dashmap = "6" +dialoguer = "0.12" +dotenv = "0.15.0" +env_logger = "0.11" fluent = "0.17" fluent-bundle = "0.16" fluent-syntax = "0.12" -unic-langid = { version = "0.9", features = ["unic-langid-macros"] } - -tokio = { version = "1.49", features = ["rt-multi-thread"]} -tower = "0.5.3" -tower-http = { version = "0.6.8", features = ["fs"]} - -hex = "0.4.3" -dotenv = "0.15.0" -async-trait = "0.1.89" - -once_cell = "1.21.3" -fluent-templates = { version = "0.13.2", features = ["tera"]} - -rhai = { version = "1.24", features = ["serde", "only_i64", "no_float"] } - -# Email support -lettre = { version = "0.11", features = ["tokio1-native-tls", "smtp-transport", "pool", "hostname", "builder"] } -handlebars = { version = "6.4" } -urlencoding = { version = "2.1" } - -# TLS Support (optional) -axum-server = { version = "0.8", features = ["tls-rustls"] } -axum-test = "18.7" -rustls = { version = "0.23" } -rustls-pemfile = { version = "2.2" } - -# Authentication & Authorization (optional) -jsonwebtoken = { version = "10.3", features = ["rust_crypto"] } -argon2 = { version = "0.5" } -uuid = { version = "1.20", features = ["v4", "serde", "js"] } -chrono = { version = "0.4", features = ["serde"] } -oauth2 = { version = "5.0" } -tower-sessions = { version = "0.15" } -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "sqlite", "chrono", "uuid", "migrate"] } -tower-cookies = { version = "0.11" } -time = { version = "0.3", features = ["serde"] } - -# 2FA Support (optional) -totp-rs = { version = "5.7.0" } -qrcode = { version = "0.14", features = ["svg"] } -base32 = { version = "0.5" } -sha2 = { version = "0.10" } -base64 = { version = "0.22" } - -# Cryptography dependencies -aes-gcm = { version = "0.10" } -clap = { version = "4.5", features = ["derive"] } - -# Metrics dependencies -prometheus = { version = "0.14" } - -# Content Management & Rendering (optional) -pulldown-cmark = { version = "0.13.0", features = ["simd"] } -serde_yaml = { version = "0.9" } -tempfile = { version = "3.24" } -tera = { version = "1.20" } -unicode-normalization = { version = "0.1" } - -paste = "1.0.15" -typed-builder = "0.23" - -notify = { version = "8.2.0", default-features = false, features = ["macos_fsevent"] } -lru = "0.16" -ammonia = "4.1" -scraper = "0.25" -futures = "0.3.31" -async-compression = { version = "0.4", features = ["gzip", "tokio"] } - -ratatui = "0.30" -inquire = "0.9" -crossterm = "0.29" -syntect = "5.3" -similar = "2.7" -reactive_graph = "0.2.12" - -syn = { version = "2.0", features = ["full"] } -comrak = { version = "0.50", features = ["syntect"] } - -walkdir = "2.5" -quote = "1.0" -proc-macro2 = "1.0" +fluent-templates = { version = "0.14.0", features = ["tera"] } +futures = "0.3.32" +getrandom = { version = "0.4", features = ["std", "wasm_js"] } +glob = "0.3.3" +gloo-net = "0.7.0" +gloo-timers = { version = "0.4", features = ["futures"] } gray_matter = "0.3" -ignore = "0.4" -mockall = "0.14" -wiremock = "0.6" -cfg-if = "1.0" +handlebars = "6.4" +hex = "0.4.3" html-escape = "0.2" - -shellexpand = "3.1" -semver = "1.0" -pathdiff = "0.2" - -dialoguer = "0.12" -console = "0.16" +http = "1" +ignore = "0.4" indicatif = "0.18" +inquire = "0.9" +js-sys = "=0.3.97" +jsonwebtoken = { version = "10.4", features = ["rust_crypto"] } +leptos = "=0.8.15" +leptos_axum = "=0.8.7" +leptos_config = "=0.8.8" +leptos_integration_utils = "=0.8.7" +leptos_meta = "=0.8.5" +leptos_router = "=0.8.11" +lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "pool", "hostname", "builder"] } +log = "0.4.30" +lru = "0.18" +minijinja = { version = "2", features = ["loader", "builtins", "json"] } +mockall = "0.14" +nkeys = "0.4" +notify = { version = "8.2.0", default-features = false } +oauth2 = "5.0" +once_cell = "1.21.4" +paste = "1.0.15" +pathdiff = "0.2" +platform-nats = { path = "../../stratumiops/code/crates/platform-nats" } +proc-macro2 = "1.0" +prometheus = "0.14" +pulldown-cmark = { version = "0.13", features = ["simd"] } +qrcode = { version = "0.14", features = ["svg"] } +quote = "1.0" +rand = "0.10" +rand_core = "0.10" +ratatui = "0.30" +reactive_graph = "0.2.14" +regex = "1.12.3" +reqwasm = "0.5.0" +reqwest = { version = "0.13.4", features = ["json"] } +rhai = { version = "1.25", features = ["serde", "only_i64", "no_float"] } +rustelo_auth = { path = "crates/framework/crates/rustelo_auth" } +rustelo_cli = { path = "crates/framework/crates/rustelo_cli" } +rustelo_client = { path = "crates/foundation/crates/rustelo_client" } +rustelo_components_htmx = { path = "crates/foundation/crates/rustelo_components_htmx" } +rustelo_components_leptos = { path = "crates/foundation/crates/rustelo_components_leptos" } +rustelo_config = { path = "crates/foundation/crates/rustelo_config" } +rustelo_content = { path = "crates/framework/crates/rustelo_content" } +rustelo_core = { path = "crates/framework/crates/rustelo_core" } +rustelo_core_lib = { path = "crates/foundation/crates/rustelo_core_lib" } +rustelo_core_types = { path = "crates/foundation/crates/rustelo_core_types" } +rustelo_language = { path = "crates/foundation/crates/rustelo_language" } +rustelo_macros = { path = "crates/foundation/crates/rustelo_macros" } +rustelo_pages_htmx = { path = "crates/foundation/crates/rustelo_pages_htmx" } +rustelo_pages_leptos = { path = "crates/foundation/crates/rustelo_pages_leptos" } +rustelo_routing = { path = "crates/foundation/crates/rustelo_routing" } +rustelo_seo = { path = "crates/foundation/crates/rustelo_seo" } +rustelo_server = { path = "crates/foundation/crates/rustelo_server", default-features = false } +rustelo_tools = { path = "crates/foundation/crates/rustelo_tools" } +rustelo_utils = { path = "crates/foundation/crates/rustelo_utils" } +rustelo_web = { path = "crates/framework/crates/rustelo_web" } +rustls = "0.23" +rustls-pemfile = "2.2" +scraper = "0.27" +semver = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde-wasm-bindgen = "0.6.5" +serde_json = "1.0" +serde_yaml = "0.9" +sha2 = "0.11" +shellexpand = "3.1" +similar = "3.1" +sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls-ring", "postgres", "sqlite", "chrono", "uuid", "migrate"] } +syn = { version = "2.0", features = ["full"] } +syntect = "5.3" +tempfile = "3.27" +tera = "1.20" +thiserror = "2.0.18" +time = { version = "0.3", features = ["serde"] } +tokio = { version = "1.52", features = ["rt-multi-thread"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tokio-test = "0.4" +tokio-util = { version = "0.7", features = ["io"] } +toml = "1.1.2" +totp-rs = "5.7" +tower = "0.5.3" +tower-cookies = "0.11" +tower-http = { version = "0.6.11", features = ["fs"] } +tower-sessions = "0.15" +tracing = "0.1" +tracing-subscriber = "0.3" +typed-builder = "0.23" +unic-langid = { version = "0.9", features = ["unic-langid-macros"] } +unicode-normalization = "0.1" +urlencoding = "2.1" +uuid = { version = "1.23", features = ["v4", "serde", "js"] } +walkdir = "2.5" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "=0.3.97", features = ["Clipboard", "Window", "Navigator", "Permissions", "MouseEvent", "KeyboardEvent", "Event", "Storage", "console", "File", "SvgElement", "SvgsvgElement", "SvgPathElement", "MediaQueryList"] } +wiremock = "0.6" +# <<< rustelo-sync:workspace-deps [profile.release] codegen-units = 1 diff --git a/crates/foundation/2026-02-23-020521-this-session-is-being-continued-from-a-previous-co.txt b/crates/foundation/2026-02-23-020521-this-session-is-being-continued-from-a-previous-co.txt new file mode 100644 index 0000000..70d47ed --- /dev/null +++ b/crates/foundation/2026-02-23-020521-this-session-is-being-continued-from-a-previous-co.txt @@ -0,0 +1,306 @@ + + ✻ + | + ▟█▙ Claude Code v2.1.50 + ▐▛███▜▌ Sonnet 4.6 · Claude Max +▝▜█████▛▘ /Users/Akasha/Development/evol-rustelo/rustelo/crates/foundation + ▘▘ ▝▝ + +✻ Conversation compacted (ctrl+o for history) + + ⎿  Read ../../../website/website-impl/target/front/wasm32-unknown-unknown/debu + g/build/website-client-857cf649d398b40d/out/client_routes.rs (135 lines) + ⎿  Read ../../../website/website-impl/target/front/wasm32-unknown-unknown/debu + g/build/website-client-857cf649d398b40d/out/page_provider_impl.rs (92 + lines) + ⎿  Read ../../../website/website-impl/target/front/wasm32-unknown-unknown/debu + g/build/website-client-857cf649d398b40d/out/menu_registry_fn.rs (140 lines) + ⎿  Read crates/rustelo_components/src/navigation/language_selector/ssr.rs (59 + lines) + ⎿  Read crates/rustelo_components/src/navigation/language_selector/client.rs + (74 lines) + +⏺ Let me read the current state of the files before modifying them. + +⏺ Read 2 files (ctrl+o to expand) + +⏺ Now let me read shell.rs: + +⏺ Read 2 files (ctrl+o to expand) + +⏺ Let me check what render_page_content_ssr does in the server routing: + +⏺ Searched for 2 patterns, read 1 file (ctrl+o to expand) + +⏺ Let me check the PageProvider trait signature: + +⏺ Searched for 9 patterns, read 3 files (ctrl+o to expand) + +⏺ ★ Insight ───────────────────────────────────── + Three layered bugs to fix: + 1. Routing: render_page_content_with_language is a stub → generated + WebsitePageProvider exists but is disconnected from routing + 2. DynChild mismatch: SSR renders static (no markers), WASM uses move || {} + (expects markers) → hydration cursor misalignment → "callback removed before + attaching" + 3. Missing reverse lookup: client_routes.rs has path→const but no + path→component reverse lookup + ───────────────────────────────────────────────── + + Now implementing all three fixes: + + Step 1: build.rs — add get_component_for_path generation + +⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/clie + nt/build.rs) + ⎿  Added 19 lines + 382 let routes_dir = rustelo_utils::routes_path(); + 383 + 384 let mut all_routes = HashMap::new(); + 385 + let mut path_to_component: HashMap = HashM + +ap::new(); + 386 + 387 // Create default route structure (used as fallback) + 388 let default_routes = { + ... + 396 if !routes_dir.exists() { + 397 println!("cargo:warning=No routes configuration found, + generating defaults"); + 398 all_routes = default_routes; + 399 + path_to_component.insert("/".to_string(), "Home".to_st + +ring()); + 400 } else { + 401 // Use typed route loading (NCL-first, TOML-fallback) + 402 match build_config::route_config::load_all_routes(&rou + tes_dir) { + ... + 417 + 418 // Use component name as route name fo + r consistency + 419 lang_routes.insert(route.component.to_ + lowercase(), route.path.clone()); + 420 + + 421 + // Reverse lookup: path → component (f + +irst language wins per path) + 422 + path_to_component + 423 + .entry(route.path.clone()) + 424 + .or_insert_with(|| route.component + +.clone()); + 425 } + 426 + 427 all_routes.insert(lang, lang_routes); + ... + 430 Err(e) => { + 431 eprintln!("Warning: Failed to load routes: {}. + Using defaults.", e); + 432 all_routes = default_routes; + 433 + path_to_component.insert("/".to_string(), "Hom + +e".to_string()); + 434 } + 435 } + 436 } + ... + 465 _ => None, + 466 } + 467 } + 468 + + 469 + /// Get component name for a given path (reverse route loo + +kup) + 470 + pub fn get_component_for_path(path: &str) -> Option<&'stat + +ic str> { + 471 + match path { + 472 + {% for route_path, component_name in path_to_compo + +nent %} + 473 + "{{ route_path }}" => Some("{{ component_name }}") + +, + 474 + {% endfor %} + 475 + _ => None, + 476 + } + 477 + } + 478 } + 479 "#; + 480 + ... + 482 + 483 let mut context = Context::new(); + 484 context.insert("languages", &all_routes); + 485 + context.insert("path_to_component", &path_to_component); + 486 + 487 let generated_code = tera.render("client_routes", &context + )?; + 488 + +⏺ Step 2: app.rs — replace stub with generated WebsitePageProvider + +⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/clie + nt/src/app.rs) + ⎿  Added 1 line + 13 i18n::{provide_unified_i18n, UnifiedI18n}, + 14 routing::utils::detect_language_from_path, + 15 state::LanguageProvider, + 16 + PageProvider, + 17 }; + 18 #[cfg(target_arch = "wasm32")] + 19 use std::time::Duration; + ⎿  Found 25 new diagnostic issues in 5 files (ctrl+o to expand) + +⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/clie + nt/src/app.rs) + ⎿  Added 12 lines, removed 1 line + 165 #[cfg(target_arch = "wasm32")] + 166 web_sys::console::log_1(&format!(" + 🎬 App main content render: path='{}', language='{}' (content + re-rendering)", current_path, current_lang).into()); + 167 + 168 - rustelo_client::routing::render_pa + -ge_content_with_language(¤t_path, ¤t_lang) + 168 + { + 169 + let component = crate::routes: + +:get_component_for_path(¤t_path) + 170 + .unwrap_or("NotFound") + 171 + .to_string(); + 172 + crate::WebsitePageProvider + 173 + .render_component( + 174 + &component, + 175 + std::collections::Hash + +Map::new(), + 176 + ¤t_lang, + 177 + ) + 178 + .unwrap_or_else(|_| view! + +{
"Page not found"
}.into_any()) + 179 + } + 180 } + 181 } + 182 + ⎿  Found 19 new diagnostic issues in 3 files (ctrl+o to expand) + +⏺ Step 3: shell.rs — add move || {} wrappers for DynChild markers + +⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/serv + er/src/shell.rs) + ⎿  Added 12 lines, removed 3 lines + 96 let language_clone = language.clone(); + 97 let current_path_clone = current_path.clone(); + 98 + 99 + // Pre-clone before closures to avoid move conflicts + 100 + let footer_lang = language_clone.clone(); + 101 + + 102 // Create the app component that properly establishes Owne + r context for SSR + 103 let app = move || { + 104 // Establish proper Owner context for SSR rendering + ... + 113 /> + 114
+ 115 { + 113 - // Use the foundation routing syst + -em directly (PAP-compliant!) + 114 - rustelo_server::routing::render_pa + -ge_content_ssr(¤t_path_clone) + 116 + // DynChild wrapper: emits markers that match + 117 + // the client's move || {} reactiv + +e boundary in app.rs + 118 + move || rustelo_server::routing::r + +ender_page_content_ssr(¤t_path_clone) + 119 } + 120
+ 117 - + 121 + { + 122 + // DynChild wrapper: emits markers mat + +ching client's move || {} footer closure + 123 + move || view! { + 124 + + 125 + } + 126 + } + 127 + 128 + 129 } + ⎿  Found 13 new diagnostic issues in 2 files (ctrl+o to expand) + +⏺ Let me verify the complete state of the changed files: + +⏺ Read 1 file (ctrl+o to expand) + ⎿  Found 13 new diagnostic issues in 2 files (ctrl+o to expand) + +⏺ Let me verify the shell.rs changes: + +⏺ Read 1 file (ctrl+o to expand) + +⏺ There's a potential type inference issue with HashMap::new() - let me make it + explicit: + +⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/clie + nt/src/app.rs) + ⎿  Added 3 lines, removed 5 lines + 169 let component = crate::routes: + :get_component_for_path(¤t_path) + 170 .unwrap_or("NotFound") + 171 .to_string(); + 172 + let props: std::collections::H + +ashMap = + 173 + std::collections::HashMap: + +:new(); + 174 crate::WebsitePageProvider + 173 - .render_component( + 174 - &component, + 175 - std::collections::Hash + -Map::new(), + 176 - ¤t_lang, + 177 - ) + 175 + .render_component(&compone + +nt, props, ¤t_lang) + 176 .unwrap_or_else(|_| view! + {
"Page not found"
}.into_any()) + 177 } + 178 } + +⏺ Now let me do a final review of all three changed files: + +⏺ Read 2 files (ctrl+o to expand) + ⎿  Found 13 new diagnostic issues in 2 files (ctrl+o to expand) + +⏺ ★ Insight ───────────────────────────────────── + Three changes work together as a coherent fix: + - build.rs: or_insert_with in the HashMap ensures the first language to map a + path wins — so /about → About even if multiple languages declare that path + - app.rs: The inner {} block inside move || {} is idiomatic Rust for grouping + statements with a final expression value — needed because the closure already + owns the outer move's captures + - shell.rs: SSR DynChild closures (move || expr) evaluate immediately during + SSR but emit markers — no performance penalty, pure structural + alignment with the WASM tree + ───────────────────────────────────────────────── + + All three changes are in place. Summary of what was done: + + crates/client/build.rs — generate_client_routes(): + - Added path_to_component: HashMap populated alongside + all_routes + - Extended the Tera template with pub fn get_component_for_path(path: &str) -> + Option<&'static str> reverse lookup + - Added path_to_component to the template context + + crates/client/src/app.rs: + - Added PageProvider to the rustelo_core_lib imports (needed to call trait + method) + - Replaced the stub + rustelo_client::routing::render_page_content_with_language(...) with + crate::routes::get_component_for_path(path) → + crate::WebsitePageProvider.render_component(component, props, lang) + + crates/server/src/shell.rs: + - Pre-cloned language_clone into footer_lang before the app closure + - Wrapped page content in move || + rustelo_server::routing::render_page_content_ssr(...) to emit DynChild markers + - Wrapped footer in move || view! { } to emit matching + DynChild markers + + Next build triggers re-generation of client_routes.rs with the new + get_component_for_path function. After that, pages should route correctly and + the hydration panic should be resolved. + +✻ Churned for 51m 20s diff --git a/crates/foundation/crates/rustelo_client/Cargo.toml b/crates/foundation/crates/rustelo_client/Cargo.toml index 0cd34f5..2a22e99 100644 --- a/crates/foundation/crates/rustelo_client/Cargo.toml +++ b/crates/foundation/crates/rustelo_client/Cargo.toml @@ -44,11 +44,11 @@ fluent = { workspace = true } fluent-bundle = { workspace = true } unic-langid = { workspace = true } -rustelo_core_lib = { workspace = true } +rustelo_core_lib = { workspace = true, features = ["leptos-hydration"] } rustelo_core_types = { workspace = true } -rustelo_components = { workspace = true } +rustelo_components_leptos = { workspace = true } -rustelo_pages = { workspace = true } +rustelo_pages_leptos = { workspace = true } gloo-timers = { workspace = true } wasm-bindgen-futures = { workspace = true } chrono = { workspace = true } @@ -60,6 +60,9 @@ paste = { workspace = true } [features] default = [] hydrate = ["leptos/hydrate"] +# Gate the wasm-bindgen hydrate() entry point — disable when used as a library +# dependency by another crate that provides its own hydrate() entry point. +hydrate-entrypoint = [] content-static = [] content-watcher = ["content-static"] tls = [] diff --git a/crates/foundation/crates/rustelo_client/examples/custom_extensions.rs b/crates/foundation/crates/rustelo_client/examples/custom_extensions.rs index 03fd2f2..6acfd0c 100644 --- a/crates/foundation/crates/rustelo_client/examples/custom_extensions.rs +++ b/crates/foundation/crates/rustelo_client/examples/custom_extensions.rs @@ -3,9 +3,11 @@ //! Demonstrates the 10% extension pattern - when TOML configuration isn't //! sufficient and you need custom client-side behavior beyond what //! configuration can express. +#![allow(dead_code, unused_variables, unexpected_cfgs)] use leptos::prelude::*; -use rustelo_client::{AppComponent, ClientBuilder, HydrationOptions}; +use leptos::task::spawn_local; +use serde_json::json; use wasm_bindgen::prelude::*; use web_sys::{console, window}; @@ -19,7 +21,7 @@ pub fn hydrate_with_custom_extensions() { console::log_1(&"============================".into()); // Foundation provides 90% through TOML configuration - let base_config = rustelo_client::ClientConfig::from_generated_routes(); + let base_config = ClientConfig::from_generated_routes(); // Add 10% custom extensions when configuration isn't sufficient let extended_config = base_config @@ -29,7 +31,7 @@ pub fn hydrate_with_custom_extensions() { .with_service_worker(setup_offline_capabilities()); // Hydrate with both generated + custom functionality - rustelo_client::hydrate_with_config(extended_config); + hydrate_with_config(extended_config); console::log_1(&"✨ Extended hydration complete with custom features!".into()); } @@ -117,15 +119,17 @@ fn setup_client_analytics() -> AnalyticsConfig { .with_error_tracking(true) .with_custom_events(vec![ // Track custom business events - CustomEvent::new("dashboard_interaction").with_properties(|interaction| { - json!({ - "component": interaction.component_name, - "action": interaction.action_type, - "timestamp": js_sys::Date::now(), - "user_session": get_session_id() - }) - }), - CustomEvent::new("feature_usage").with_properties(|usage| { + CustomEvent::new("dashboard_interaction").with_properties( + |interaction: &Interaction| { + json!({ + "component": interaction.component_name, + "action": interaction.action_type, + "timestamp": js_sys::Date::now(), + "user_session": get_session_id() + }) + }, + ), + CustomEvent::new("feature_usage").with_properties(|usage: &FeatureUsage| { json!({ "feature_name": usage.feature, "usage_duration": usage.duration_ms, @@ -182,24 +186,26 @@ pub fn EnhancedDashboard() -> impl IntoView { console::log_1(&"🎨 Rendering Enhanced Dashboard with custom extensions".into()); // Get the base component generated from TOML configuration (90%) - let base_dashboard = rustelo_client::get_generated_component("Dashboard"); + let base_dashboard = get_generated_component("Dashboard"); // Add custom client-side state for extensions (10%) - let (realtime_metrics, set_realtime_metrics) = create_signal(Vec::new()); - let (notification_count, set_notification_count) = create_signal(0); - let (is_offline, set_is_offline) = create_signal(false); + // RwSignal uses SyncStorage — Send + Sync — required by IntoClass for class: + // conditionals. + let realtime_metrics: RwSignal> = RwSignal::new(Vec::new()); + let notification_count = RwSignal::new(0i32); + let is_offline = RwSignal::new(false); // Custom WebSocket effect for real-time data - create_effect(move |_| { + Effect::new(move |_| { spawn_local(async move { match establish_websocket_connection().await { - Ok(ws_stream) => { + Ok(mut ws_stream) => { console::log_1(&"📡 Real-time connection established".into()); // Listen for real-time updates while let Some(message) = ws_stream.next().await { if let Ok(metrics) = parse_metrics_message(&message) { - set_realtime_metrics.set(metrics); + realtime_metrics.set(metrics); } } } @@ -211,20 +217,23 @@ pub fn EnhancedDashboard() -> impl IntoView { }); // Custom offline detection - create_effect(move |_| { - let navigator = window().unwrap().navigator(); - let is_online = navigator.on_line(); - set_is_offline.set(!is_online); - - if !is_online { - console::log_1(&"🔌 Application is now offline - enabling offline features".into()); + Effect::new(move |_| { + if let Some(win) = window() { + let navigator = win.navigator(); + let is_online = navigator.on_line(); + is_offline.set(!is_online); + if !is_online { + console::log_1(&"🔌 Application is now offline - enabling offline features".into()); + } } }); // Keyboard shortcuts for power users - create_effect(move |_| { - let window = window().unwrap(); - let document = window.document().unwrap(); + Effect::new(move |_| { + let Some(win) = window() else { return }; + let Some(document) = win.document() else { + return; + }; let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| { if event.ctrl_key() { @@ -232,12 +241,10 @@ pub fn EnhancedDashboard() -> impl IntoView { "d" => { event.prevent_default(); console::log_1(&"⌨️ Keyboard shortcut: Focus dashboard".into()); - // Custom dashboard focus logic } "n" => { event.prevent_default(); console::log_1(&"⌨️ Keyboard shortcut: New item".into()); - // Custom new item creation logic } "?" => { event.prevent_default(); @@ -248,26 +255,25 @@ pub fn EnhancedDashboard() -> impl IntoView { } }) as Box); - document - .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()) - .unwrap(); + let _ = + document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref()); - closure.forget(); // Keep the closure alive + closure.forget(); }); view! {
// Status indicators for custom features
-
+
"📡 " {move || if realtime_metrics.get().is_empty() { "Connecting..." } else { "Live" }}
-
0> - "🔔 " {notification_count} +
0}> + "🔔 " {move || notification_count.get().to_string()}
-
+
{move || if is_offline.get() { "🔌 Offline Mode" } else { "🌐 Online" }}
@@ -294,19 +300,20 @@ pub fn EnhancedDashboard() -> impl IntoView { /// /// Custom component that displays live data from WebSocket connections #[component] -fn RealtimeMetricsPanel(metrics: ReadSignal>) -> impl IntoView { +fn RealtimeMetricsPanel(metrics: RwSignal>) -> impl IntoView { view! {

"📊 Live Metrics"

{move || { - metrics.get().iter().map(|metric| { + metrics.get().into_iter().map(|metric| { + let trend = metric.trend; view! { -
-
{&metric.value}
-
{&metric.label}
-
0.0 class:negative=metric.trend < 0.0> - {format!("{:+.1}%", metric.trend)} +
+
{metric.value}
+
{metric.label}
+
0.0} class:negative={trend < 0.0}> + {format!("{:+.1}%", trend)}
} @@ -322,14 +329,14 @@ fn RealtimeMetricsPanel(metrics: ReadSignal>) -> impl IntoView { /// Custom charting with advanced interactivity #[component] fn AdvancedCharting() -> impl IntoView { - let (chart_data, set_chart_data) = create_signal(Vec::new()); - let (chart_type, set_chart_type) = create_signal(ChartType::Line); + let chart_data = RwSignal::new(Vec::::new()); + let chart_type = RwSignal::new(ChartType::Line); // Custom chart initialization - create_effect(move |_| { + Effect::new(move |_| { spawn_local(async move { match load_advanced_chart_data().await { - Ok(data) => set_chart_data.set(data), + Ok(data) => chart_data.set(data), Err(e) => console::log_1(&format!("Chart loading error: {}", e).into()), } }); @@ -340,7 +347,7 @@ fn AdvancedCharting() -> impl IntoView {
{initial_grid_html}"##, + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn carries_morph_swap() { + let html = render_content_grid("blog", ""); + assert!(html.contains(r#"hx-swap="morph""#)); + } + + #[test] + fn debounces_search_typing() { + let html = render_content_grid("blog", ""); + assert!(html.contains("keyup changed delay:300ms")); + } + + #[test] + fn includes_form_state_via_hx_include() { + let html = render_content_grid("blog", ""); + assert!(html.contains(r#"hx-include="this""#)); + } + + #[test] + fn replaces_in_flight_requests() { + let html = render_content_grid("blog", ""); + assert!(html.contains(r#"hx-sync="this:replace""#)); + } + + #[test] + fn endpoint_path_matches_kind() { + let html = render_content_grid("recipes", ""); + assert!(html.contains("/api/htmx/content/recipes")); + } + + #[test] + fn embeds_initial_html() { + let html = render_content_grid("blog", "
    INITIAL
"); + assert!(html.contains("INITIAL")); + } +} diff --git a/crates/foundation/crates/rustelo_components_htmx/src/content/mod.rs b/crates/foundation/crates/rustelo_components_htmx/src/content/mod.rs new file mode 100644 index 0000000..3bb448f --- /dev/null +++ b/crates/foundation/crates/rustelo_components_htmx/src/content/mod.rs @@ -0,0 +1,7 @@ +//! Content grids, cards and paginators. Implemented in F-08. + +mod card; +mod grid; + +pub use card::render_content_card; +pub use grid::render_content_grid; diff --git a/crates/foundation/crates/rustelo_components_htmx/src/forms/field.rs b/crates/foundation/crates/rustelo_components_htmx/src/forms/field.rs new file mode 100644 index 0000000..40ae70b --- /dev/null +++ b/crates/foundation/crates/rustelo_components_htmx/src/forms/field.rs @@ -0,0 +1,88 @@ +//! Generic form field with inline validation feedback. F-11. + +use html_escape::encode_double_quoted_attribute; + +use crate::primitives::htmx_path; + +/// Input kind. Drives the `` `type` attribute. +#[derive(Debug, Clone, Copy)] +pub enum FieldKind { + Text, + Email, + Password, + Tel, + Url, + TextArea, +} + +impl FieldKind { + pub fn input_type(self) -> &'static str { + match self { + FieldKind::Text => "text", + FieldKind::Email => "email", + FieldKind::Password => "password", + FieldKind::Tel => "tel", + FieldKind::Url => "url", + FieldKind::TextArea => "textarea", + } + } +} + +/// Render a single form field with `hx-trigger="blur"` validation pointing at +/// `/api/htmx/validate/{form}/{name}`. +pub fn render_field(form: &str, name: &str, label: &str, kind: FieldKind) -> String { + let endpoint = htmx_path(&format!("validate/{form}/{name}")); + let id = format!("field-{form}-{name}"); + let body = if matches!(kind, FieldKind::TextArea) { + format!( + r#""#, + id = encode_double_quoted_attribute(&id), + name = encode_double_quoted_attribute(name), + endpoint = encode_double_quoted_attribute(&endpoint), + ) + } else { + format!( + r#""#, + ty = kind.input_type(), + id = encode_double_quoted_attribute(&id), + name = encode_double_quoted_attribute(name), + endpoint = encode_double_quoted_attribute(&endpoint), + ) + }; + format!( + r#"
{body}
"#, + id = encode_double_quoted_attribute(&id), + label = html_escape::encode_text(label), + name = encode_double_quoted_attribute(name), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emits_per_field_validate_endpoint() { + let html = render_field("contact", "email", "Email", FieldKind::Email); + assert!(html.contains(r#"hx-post="/api/htmx/validate/contact/email""#)); + } + + #[test] + fn triggers_on_blur() { + let html = render_field("contact", "email", "Email", FieldKind::Email); + assert!(html.contains(r#"hx-trigger="blur""#)); + } + + #[test] + fn renders_textarea_for_textarea_kind() { + let html = render_field("contact", "msg", "Message", FieldKind::TextArea); + assert!(html.contains(" { + pub title: &'a str, + pub description: &'a str, + pub copyright: &'a str, + /// Each entry: `(display_name, url, icon_class)`. + pub social_links: &'a [(&'a str, &'a str, &'a str)], + /// Footer nav links: `(label, href)`. + pub nav_links: &'a [(&'a str, &'a str)], +} + +/// Render the site footer with site-specific config. +pub fn render_footer_with_config(config: &FooterConfig<'_>, _lang: &str) -> String { + let social_html = config + .social_links + .iter() + .map(|(name, url, icon)| { + format!( + r##""##, + url = encode_double_quoted_attribute(url), + name = encode_double_quoted_attribute(name), + icon = encode_double_quoted_attribute(icon), + ) + }) + .collect::(); + + let nav_html = config + .nav_links + .iter() + .map(|(label, href)| { + format!( + r##"{label}"##, + href = encode_double_quoted_attribute(href), + label = encode_text(label), + ) + }) + .collect::(); + + format!( + r##"

{title}

{desc}

{social}

{copy}

{nav}
"##, + title = encode_text(config.title), + desc = encode_text(config.description), + social = social_html, + copy = encode_text(config.copyright), + nav = nav_html, + ) +} + +/// Minimal footer fallback used when no `FooterConfig` is available. +pub fn render_footer(lang: &str) -> String { + let _ = lang; + r#"
© Jesús Pérez
"#.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_social_links() { + let cfg = FooterConfig { + title: "Test", + description: "Desc", + copyright: "© 2024", + social_links: &[("GitHub", "https://github.com/x", "fab fa-github")], + nav_links: &[], + }; + let html = render_footer_with_config(&cfg, "en"); + assert!(html.contains("https://github.com/x")); + assert!(html.contains("fab fa-github")); + } + + #[test] + fn renders_nav_links() { + let cfg = FooterConfig { + title: "T", + description: "D", + copyright: "C", + social_links: &[], + nav_links: &[("Privacy", "/privacy")], + }; + let html = render_footer_with_config(&cfg, "en"); + assert!(html.contains("/privacy")); + assert!(html.contains("Privacy")); + } +} diff --git a/crates/foundation/crates/rustelo_components_htmx/src/navigation/lang_selector.rs b/crates/foundation/crates/rustelo_components_htmx/src/navigation/lang_selector.rs new file mode 100644 index 0000000..a9baf84 --- /dev/null +++ b/crates/foundation/crates/rustelo_components_htmx/src/navigation/lang_selector.rs @@ -0,0 +1,90 @@ +//! Language selector — dropdown using native `
` so it opens/closes +//! without JavaScript. Mirrors the Leptos `LanguageSelector` look (globe icon +//! summary, panel of options) but the options are HTMX `hx-post` triggers +//! that go to `/api/htmx/lang/{code}`. The server responds with the new +//! selector fragment + `HX-Redirect` to the localised URL. + +use crate::primitives::htmx_path; + +/// Render the language selector for the currently active language. +pub fn render_lang_selector(current_lang: &str) -> String { + let languages: [(&str, &str); 2] = [("en", "English"), ("es", "Español")]; + let langs: Vec = languages + .iter() + .map(|(code, label)| { + let endpoint = htmx_path(&format!("lang/{code}")); + minijinja::context! { + code => code, + label => label, + endpoint => endpoint, + is_active => *code == current_lang, + } + }) + .collect(); + + let ctx = minijinja::context! { languages => langs }; + rustelo_pages_htmx::fragment_env() + .and_then(|env| { + env.get_template("partials/nav/lang_selector.j2") + .and_then(|t| t.render(ctx)) + .ok() + }) + .unwrap_or_else(|| fallback_lang_selector(current_lang, &languages)) +} + +fn fallback_lang_selector(current_lang: &str, languages: &[(&str, &str)]) -> String { + let mut options = String::new(); + for (code, label) in languages { + let endpoint = htmx_path(&format!("lang/{code}")); + let active = if *code == current_lang { + r#" aria-current="true" disabled"# + } else { + "" + }; + options.push_str(&format!( + r##""##, + ep = html_escape::encode_double_quoted_attribute(&endpoint), + )); + } + format!( + r##"
🌐
{options}
"## + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emits_two_buttons() { + let html = render_lang_selector("en"); + assert_eq!(html.matches("` update). + +mod footer; +mod lang_selector; +mod nav_menu; +mod theme_toggle; + +pub use footer::{render_footer, render_footer_with_config, FooterConfig}; +pub use lang_selector::render_lang_selector; +pub use nav_menu::{render_nav_menu, NavItem}; +pub use theme_toggle::{render_theme_toggle, Theme}; diff --git a/crates/foundation/crates/rustelo_components_htmx/src/navigation/nav_menu.rs b/crates/foundation/crates/rustelo_components_htmx/src/navigation/nav_menu.rs new file mode 100644 index 0000000..8bcd626 --- /dev/null +++ b/crates/foundation/crates/rustelo_components_htmx/src/navigation/nav_menu.rs @@ -0,0 +1,133 @@ +//! Top navigation menu emitted with `hx-boost`-friendly anchors. +//! +//! Anchors carry plain `href` attributes — boost on `` upgrades them to +//! HTMX-driven navigations. The current route is computed server-side so the +//! active-item highlight is part of the SSR output (no client JS needed). + +use html_escape::encode_double_quoted_attribute; +use serde::{Deserialize, Serialize}; + +/// One row in the navigation menu. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NavItem { + /// Localised label already resolved against the active language. + pub label: String, + /// Destination URL. Internal routes get `hx-boost` treatment via the + /// surrounding ``; external links get `target="_blank"`. + pub route: String, + /// `true` if the link points outside the site. + pub is_external: bool, +} + +/// Render the navigation menu as a `
- // Submit Button -
- + // Submit Button + Feedback +
+ { + let success_label = content.t("contact-submit-success"); + move || { + #[cfg(target_arch = "wasm32")] + let ok = submit_ok.get(); + #[cfg(not(target_arch = "wasm32"))] + let ok = submit_ok.get_untracked(); + ok.then(|| view! { +
+ {success_label.clone()} +
+ }) + } + } + {move || { + #[cfg(target_arch = "wasm32")] + let err = submit_error.get(); + #[cfg(not(target_arch = "wasm32"))] + let err = submit_error.get_untracked(); + err.map(|e| view! { +
+ {e} +
+ }) + }} +
+ +
@@ -279,9 +383,20 @@ pub fn UnifiedContactPage(

{content.t("contact-consultation-help")}

- + >() { + _ev.prevent_default(); + nav::anchor_navigate(set_path, &contact_work_request_url_clone); + rustelo_core_lib::utils::doc_scroll_to_top(100); + } + } + } + > {contact_request_quote_clone} - +
// Mentoring Services diff --git a/crates/foundation/crates/rustelo_pages/src/content/category/client.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/category/client.rs similarity index 99% rename from crates/foundation/crates/rustelo_pages/src/content/category/client.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/category/client.rs index 8bcebed..1bf5b2f 100644 --- a/crates/foundation/crates/rustelo_pages/src/content/category/client.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/content/category/client.rs @@ -5,7 +5,7 @@ use leptos::prelude::*; use reactive_graph::traits::Get; -use rustelo_components::content::UnifiedContentGrid; +use rustelo_components_leptos::content::UnifiedContentGrid; use rustelo_core_lib::state::use_language; use crate::common::use_page_i18n_dynamic; diff --git a/crates/foundation/crates/rustelo_pages/src/content/category/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/category/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/content/category/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/category/mod.rs diff --git a/crates/foundation/crates/rustelo_pages/src/content/category/ssr.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/category/ssr.rs similarity index 98% rename from crates/foundation/crates/rustelo_pages/src/content/category/ssr.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/category/ssr.rs index 437416c..122a8c1 100644 --- a/crates/foundation/crates/rustelo_pages/src/content/category/ssr.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/content/category/ssr.rs @@ -2,7 +2,7 @@ //! Server-side rendered content category page that works with any content type use leptos::prelude::*; -use rustelo_components::content::UnifiedContentGrid; +use rustelo_components_leptos::content::UnifiedContentGrid; use rustelo_core_lib::i18n::{build_page_content_patterns, SsrTranslator}; /// SSR Unified Content Category Page - works with any content type category diff --git a/crates/foundation/crates/rustelo_pages/src/content/category/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/category/unified.rs similarity index 99% rename from crates/foundation/crates/rustelo_pages/src/content/category/unified.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/category/unified.rs index a3d4d21..c0a6557 100644 --- a/crates/foundation/crates/rustelo_pages/src/content/category/unified.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/content/category/unified.rs @@ -13,7 +13,7 @@ fn validate_language(lang: &str) -> bool { } // use rustelo_core_lib::content::traits::*; use leptos::prelude::*; -use rustelo_components::content::grid::UnifiedContentGrid; +use rustelo_components_leptos::content::grid::UnifiedContentGrid; //#[cfg(not(target_arch = "wasm32"))] // use rustelo_core_lib::fluent::loaders::load_content_index; diff --git a/crates/foundation/crates/rustelo_pages/src/content/helpers/cta_section.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/cta_section.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/content/helpers/cta_section.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/cta_section.rs diff --git a/crates/foundation/crates/rustelo_pages/src/content/helpers/defs.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/defs.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/content/helpers/defs.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/defs.rs diff --git a/crates/foundation/crates/rustelo_pages/src/content/helpers/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/content/helpers/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/mod.rs diff --git a/crates/foundation/crates/rustelo_pages/src/content/helpers/renderer.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/renderer.rs similarity index 99% rename from crates/foundation/crates/rustelo_pages/src/content/helpers/renderer.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/renderer.rs index 1c75414..eed17c3 100644 --- a/crates/foundation/crates/rustelo_pages/src/content/helpers/renderer.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/renderer.rs @@ -4,7 +4,7 @@ //! views by providing unified rendering methods for all content types. use leptos::prelude::*; -use rustelo_components::content::card::UnifiedContentCard; +use rustelo_components_leptos::content::card::UnifiedContentCard; use rustelo_core_lib::{ content::{ContentItemTrait, UnifiedContentItem}, create_content_kind_registry, diff --git a/crates/foundation/crates/rustelo_pages/src/content/helpers/utils.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/utils.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/content/helpers/utils.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/helpers/utils.rs diff --git a/crates/foundation/crates/rustelo_pages/src/content/index/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/index/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/content/index/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/index/mod.rs diff --git a/crates/foundation/crates/rustelo_pages/src/content/index/pages.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/index/pages.rs similarity index 83% rename from crates/foundation/crates/rustelo_pages/src/content/index/pages.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/index/pages.rs index fc35e14..b602ec3 100644 --- a/crates/foundation/crates/rustelo_pages/src/content/index/pages.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/content/index/pages.rs @@ -12,18 +12,14 @@ pub fn ContentIndexClient( // Use the existing use_page_i18n_dynamic function let page_content = crate::common::use_page_i18n_dynamic(vec!["content-", "blog-"], || vec![]); - // Clone language for use in closure - let language = _language.clone(); - let content_type = content_type.clone(); + let lang_content = page_content.get_untracked(); - move || { - view! { - - } + view! { + } } diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/content/index/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/index/unified.rs new file mode 100644 index 0000000..503e1d7 --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/content/index/unified.rs @@ -0,0 +1,85 @@ +//! Generic Content Index Page - works with ANY content type dynamically +//! +//! This component is fully configuration-driven and works with any content type +//! defined in content-kinds.toml without hardcoded logic. +//! +//! Uses safe, non-reactive patterns to avoid hydration issues and panics. + +use leptos::prelude::*; +use rustelo_components_leptos::content::simple_grid::SimpleContentGrid; +use rustelo_components_leptos::filter::UnifiedCategoryFilter; +use rustelo_core_lib::i18n::content_helper::create_content_provider; +use rustelo_core_lib::state::use_current_language; + +/// Generic Content Index Page - works with ANY content type dynamically. +/// Title and description are rendered in reactive closures so they update +/// immediately when the user switches language without a full page reload. +#[component] +pub fn UnifiedContentIndexPage( + content_type: String, + _language: String, + #[prop(optional)] lang_content: Option>, + #[prop(optional)] filter: Option, +) -> impl IntoView { + let lang_signal = use_current_language(); + let ct_h1 = content_type.clone(); + let ct_p = content_type.clone(); + + view! { +
+
+ // Page Header — reactive closures so titles update on SPA language switch +
+

+ {move || { + let lang = { + #[cfg(target_arch = "wasm32")] + { lang_signal.get() } + #[cfg(not(target_arch = "wasm32"))] + { lang_signal.get_untracked() } + }; + let translator = rustelo_core_lib::SsrTranslator::new(lang); + let lc = rustelo_core_lib::build_page_content_patterns( + &translator, &[ct_h1.as_str(), "content-"], + ); + let cp = create_content_provider(Some(lc)); + cp.t_with_prefixes("page-title", &[&ct_h1, "content"], + Some(&format!("{} Index", ct_h1))) + }} +

+

+ {move || { + let lang = { + #[cfg(target_arch = "wasm32")] + { lang_signal.get() } + #[cfg(not(target_arch = "wasm32"))] + { lang_signal.get_untracked() } + }; + let translator = rustelo_core_lib::SsrTranslator::new(lang); + let lc = rustelo_core_lib::build_page_content_patterns( + &translator, &[ct_p.as_str(), "content-"], + ); + let cp = create_content_provider(Some(lc)); + cp.t_with_prefixes("page-description", &[&ct_p, "content"], + Some(&format!("Browse all {} content", ct_p))) + }} +

+
+ + // Category and Tag Filters (Unified Implementation - works in both SSR and SPA) + + + +
+
+ } +} diff --git a/crates/foundation/crates/rustelo_pages/src/content/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/content/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/content/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/content/mod.rs diff --git a/crates/foundation/crates/rustelo_pages/src/external_link.rs b/crates/foundation/crates/rustelo_pages_leptos/src/external_link.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/external_link.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/external_link.rs diff --git a/crates/foundation/crates/rustelo_pages/src/home/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/home/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/home/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/home/mod.rs diff --git a/crates/foundation/crates/rustelo_pages/src/home/pages.rs b/crates/foundation/crates/rustelo_pages_leptos/src/home/pages.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/home/pages.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/home/pages.rs diff --git a/crates/foundation/crates/rustelo_pages/src/home/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/home/unified.rs similarity index 87% rename from crates/foundation/crates/rustelo_pages/src/home/unified.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/home/unified.rs index c0fe344..ddfd363 100644 --- a/crates/foundation/crates/rustelo_pages/src/home/unified.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/home/unified.rs @@ -5,9 +5,8 @@ //! context. use leptos::prelude::*; -use rustelo_components::HtmlContent; -use rustelo_core_lib::i18n::content_helper::create_content_provider; -#[cfg(target_arch = "wasm32")] +use rustelo_components_leptos::HtmlContent; +use rustelo_core_lib::i18n::content_helper::create_content_provider_reactive; #[cfg(target_arch = "wasm32")] use rustelo_core_lib::utils::nav; @@ -36,7 +35,7 @@ pub fn UnifiedHomePage( _language: String, #[prop(optional)] lang_content: Option>, ) -> impl IntoView { - let content = create_content_provider(lang_content.clone()); + let content = create_content_provider_reactive(lang_content.clone()); let home_services_url = content.t("home-services-url"); let home_contact_url = content.t("home-contact-url"); @@ -57,7 +56,7 @@ pub fn UnifiedHomePage( {content.t("home-hero-title")}
- jesusperez + jesusperez Jesús Pérez Profile
@@ -249,7 +248,7 @@ pub fn UnifiedHomePage( - + {content.t("home-git-repo")} @@ -260,8 +259,8 @@ pub fn UnifiedHomePage(
@@ -278,7 +277,7 @@ pub fn UnifiedHomePage( - + {content.t("home-git-repo")} @@ -289,7 +288,7 @@ pub fn UnifiedHomePage(
- AILinkra Logo + AILinkra Logo

"AILinkra"

@@ -304,7 +303,7 @@ pub fn UnifiedHomePage( - + {content.t("home-git-repo")} diff --git a/crates/foundation/crates/rustelo_pages/src/legal/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/legal/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/legal/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/legal/mod.rs diff --git a/crates/foundation/crates/rustelo_pages/src/legal/pages.rs b/crates/foundation/crates/rustelo_pages_leptos/src/legal/pages.rs similarity index 85% rename from crates/foundation/crates/rustelo_pages/src/legal/pages.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/legal/pages.rs index 0aa5848..b5e47e9 100644 --- a/crates/foundation/crates/rustelo_pages/src/legal/pages.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/legal/pages.rs @@ -11,16 +11,13 @@ pub fn LegalClient( // Use the existing use_page_i18n_dynamic function let page_content = crate::common::use_page_i18n_dynamic(vec!["legal-"], || vec![]); - // Clone language for use in closure - let language = _language.clone(); + let lang_content = page_content.get_untracked(); - move || { - view! { - - } + view! { + } } diff --git a/crates/foundation/crates/rustelo_pages/src/legal/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/legal/unified.rs similarity index 98% rename from crates/foundation/crates/rustelo_pages/src/legal/unified.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/legal/unified.rs index ced1739..37ed047 100644 --- a/crates/foundation/crates/rustelo_pages/src/legal/unified.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/legal/unified.rs @@ -1,7 +1,7 @@ //! Unified Legal Page Component use leptos::prelude::*; -use rustelo_core_lib::i18n::content_helper::create_content_provider; +use rustelo_core_lib::i18n::content_helper::create_content_provider_reactive; #[component] pub fn UnifiedLegalPage( @@ -11,7 +11,7 @@ pub fn UnifiedLegalPage( ) -> impl IntoView { // Clean closure pattern that works with Leptos ownership model // Use DRY content accessor helper - let content = create_content_provider(lang_content.clone()); + let content = create_content_provider_reactive(lang_content.clone()); view! {

diff --git a/crates/foundation/crates/rustelo_pages/src/lib.rs b/crates/foundation/crates/rustelo_pages_leptos/src/lib.rs similarity index 93% rename from crates/foundation/crates/rustelo_pages/src/lib.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/lib.rs index 20b23e7..3b67145 100644 --- a/crates/foundation/crates/rustelo_pages/src/lib.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/lib.rs @@ -18,9 +18,11 @@ pub mod contact; pub mod external_link; pub mod home; pub mod legal; +pub mod nav_log; pub mod not_found; pub mod post_viewer; pub mod privacy; +pub mod signout; pub mod user; // Unified content system @@ -40,7 +42,9 @@ pub use content::index::pages::ContentIndexPage; pub use external_link::ExternalLinkPage; pub use home::pages::HomePage; pub use legal::pages::LegalPage; +pub use nav_log::NavLogProvider; pub use not_found::pages::NotFoundPage; pub use post_viewer::pages::PostViewerPage; pub use privacy::pages::PrivacyPage; +pub use signout::pages::SignoutPage; pub use user::pages::UserPage; diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/mod.rs new file mode 100644 index 0000000..28c855b --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/mod.rs @@ -0,0 +1,6 @@ +pub mod provider; +pub mod server_fns; +pub mod types; + +pub use provider::NavLogProvider; +pub use server_fns::track_page_view; diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/provider.rs b/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/provider.rs new file mode 100644 index 0000000..aac99b0 --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/provider.rs @@ -0,0 +1,77 @@ +use leptos::prelude::*; + +/// Wraps the application shell and emits a navigation log entry on every path +/// change. Place this component once inside the app's language/auth providers +/// so it can read the shared `ReadSignal` path context and the +/// current-language context. +/// +/// The first render is logged with `nav_type = "initial"`. All subsequent +/// path changes are logged with `nav_type = "spa"`. +/// +/// On SSR this component is a transparent pass-through — tracking happens only +/// on the client (WASM) where user navigation actually occurs. +#[component] +pub fn NavLogProvider(children: Children) -> impl IntoView { + #[cfg(target_arch = "wasm32")] + { + use leptos::task::spawn_local; + use rustelo_core_lib::state::{use_current_language, AuthState}; + + use super::server_fns::track_page_view; + + let path = use_context::>(); + let lang = use_current_language(); + let auth_state = use_context::(); + // Tracks whether this is the very first effect run (initial page load). + let is_first = RwSignal::new(true); + + Effect::new(move |_| { + let p = match path { + Some(sig) => sig.get(), + None => { + web_sys::console::warn_1( + &"[NavLog] ReadSignal not found in context — tracking disabled" + .into(), + ); + return; + } + }; + + let initial = is_first.get_untracked(); + if initial { + is_first.set(false); + } + + let l = lang.get_untracked(); + let nav_type = if initial { "initial" } else { "spa" }; + let page = p.clone(); + let nt = nav_type.to_string(); + // Read user id without subscribing this effect to auth changes. + let uid = auth_state + .as_ref() + .and_then(|a| a.user.get_untracked()) + .map(|u| u.id.clone()); + + web_sys::console::log_1( + &format!( + "[NavLog] tracking: page='{}' lang='{}' type='{}' user={}", + page, + l, + nav_type, + uid.as_deref().unwrap_or("anon") + ) + .into(), + ); + + spawn_local(async move { + if let Err(e) = track_page_view(page.clone(), l, nt, uid).await { + web_sys::console::warn_1( + &format!("[NavLog] track_page_view failed for '{}': {}", page, e).into(), + ); + } + }); + }); + } + + children() +} diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/server_fns.rs b/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/server_fns.rs new file mode 100644 index 0000000..76ac9ce --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/server_fns.rs @@ -0,0 +1,74 @@ +use leptos::prelude::*; + +/// Append a navigation event to the JSONL log for this day. +/// +/// All visits (anonymous and authenticated) go to +/// `{nav_log_dir}/YYYY-MM-DD.jsonl`. The `user_id` field in the JSON entry is +/// `null` for anonymous visits and the authenticated user UUID for logged-in +/// users, enabling per-user GDPR queries with standard `jq` without splitting +/// traffic across multiple files. +#[server(TrackPageView, "/api")] +pub async fn track_page_view( + page: String, + lang: String, + nav_type: String, + user_id: Option, +) -> Result<(), ServerFnError> { + #[cfg(not(target_arch = "wasm32"))] + { + use chrono::Utc; + use tokio::fs; + use tokio::io::AsyncWriteExt; + + use super::types::{NavLogEntry, NavType}; + + let nt = match nav_type.as_str() { + "spa" => NavType::Spa, + _ => NavType::Initial, + }; + + let entry = NavLogEntry { + ts: Utc::now().to_rfc3339(), + lang, + page, + nav_type: nt, + user_id, + }; + + let line = serde_json::to_string(&entry) + .map_err(|e| ServerFnError::new(format!("serialization: {e}")))? + + "\n"; + + let base_dir = std::env::var("RUSTELO_NAV_LOG_DIR").unwrap_or_else(|_| { + let root = std::env::var("SITE_CONTENT_PATH") + .or_else(|_| std::env::var("PROJECT_ROOT")) + .unwrap_or_else(|_| ".".to_string()); + format!("{root}/logs/navigation") + }); + + let date_str = Utc::now().format("%Y-%m-%d").to_string(); + let log_path = std::path::PathBuf::from(&base_dir).join(format!("{date_str}.jsonl")); + + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| ServerFnError::new(format!("create_dir_all: {e}")))?; + } + + let mut file = fs::OpenOptions::new() + .append(true) + .create(true) + .open(&log_path) + .await + .map_err(|e| ServerFnError::new(format!("open log: {e}")))?; + + file.write_all(line.as_bytes()) + .await + .map_err(|e| ServerFnError::new(format!("write log: {e}")))?; + + return Ok(()); + } + + #[allow(unreachable_code)] + Ok(()) +} diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/types.rs b/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/types.rs new file mode 100644 index 0000000..2c49461 --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/nav_log/types.rs @@ -0,0 +1,18 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct NavLogEntry { + pub ts: String, + pub lang: String, + pub page: String, + pub nav_type: NavType, + /// Authenticated user UUID. `null` for anonymous visits. + pub user_id: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NavType { + Initial, + Spa, +} diff --git a/crates/foundation/crates/rustelo_pages/src/not_found/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/not_found/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/not_found/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/not_found/mod.rs diff --git a/crates/foundation/crates/rustelo_pages/src/not_found/pages.rs b/crates/foundation/crates/rustelo_pages_leptos/src/not_found/pages.rs similarity index 85% rename from crates/foundation/crates/rustelo_pages/src/not_found/pages.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/not_found/pages.rs index 9ab47a9..a09a1be 100644 --- a/crates/foundation/crates/rustelo_pages/src/not_found/pages.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/not_found/pages.rs @@ -12,16 +12,13 @@ pub fn NotFoundClient( let page_content = crate::common::use_page_i18n_dynamic(vec!["not-found-", "error-", "404-"], || vec![]); - // Clone language for use in closure - let language = _language.clone(); + let lang_content = page_content.get_untracked(); - move || { - view! { - - } + view! { + } } diff --git a/crates/foundation/crates/rustelo_pages/src/not_found/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/not_found/unified.rs similarity index 85% rename from crates/foundation/crates/rustelo_pages/src/not_found/unified.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/not_found/unified.rs index a39606d..0e62a3a 100644 --- a/crates/foundation/crates/rustelo_pages/src/not_found/unified.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/not_found/unified.rs @@ -1,7 +1,7 @@ //! Unified NotFound Page Component use leptos::prelude::*; -use rustelo_core_lib::i18n::content_helper::create_content_provider; +use rustelo_core_lib::i18n::content_helper::create_content_provider_reactive; #[component] pub fn UnifiedNotFoundPage( @@ -11,7 +11,7 @@ pub fn UnifiedNotFoundPage( ) -> impl IntoView { // Clean closure pattern that works with Leptos ownership model // Use DRY content accessor helper - let content = create_content_provider(lang_content.clone()); + let content = create_content_provider_reactive(lang_content.clone()); view! {
@@ -20,7 +20,7 @@ pub fn UnifiedNotFoundPage(

// {content.t("not-found-title")}

- @@ -40,10 +40,13 @@ pub fn UnifiedNotFoundPage( {content.t("not-found-go-home")} - + + {content.t("not-found-sign-in")} + + {content.t("not-found-contact-us")} - + {content.t("not-found-back-to-blog")}
diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/client.rs b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/client.rs new file mode 100644 index 0000000..27796e6 --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/client.rs @@ -0,0 +1,358 @@ +//! PostViewer page component — unified for SSR + WASM +//! +//! Both SSR and WASM go through this single component to guarantee that the +//! rendered DOM structure is identical, enabling clean Leptos 0.7 hydration. +//! +//! **SSR**: `build_initial_content` loads the markdown synchronously via +//! `load_content_by_slug` + `process_markdown_file` so the full post HTML is +//! present in the first-paint HTML (SEO + no flash). +//! +//! **WASM**: starts with the base i18n content map, then an `Effect` fires, +//! fetches the rendered content from `/api/content-render/…`, and updates +//! the `RwSignal` → the `{move || …}` DynChild re-renders in place. +//! +//! Both sides emit the same ``-bounded DynChild structure, so the +//! hydration cursor is never surprised by a missing marker node. +//! +//! Language switching: +//! After the first successful fetch the stable `id` (filename stem, same +//! across all languages) is stored. On `lang.current` change the id is used +//! as the fetch slug — the server resolves it via `item.id == slug` — +//! and the browser URL is updated to the canonical form for the new language +//! via the route config template (handles `recipes` ↔ `recetas`). + +#[cfg(not(target_arch = "wasm32"))] +use std::collections::HashMap; + +use leptos::prelude::*; +use rustelo_core_lib::{config::get_default_language, state::use_language}; +use serde::{Deserialize, Serialize}; + +use crate::common::use_page_i18n_dynamic; +use crate::post_viewer::path_resolver::PathResolver; + +/// JSON shape returned by `GET /api/content-render/{type}/{lang}/{slug}`. +/// `id` is the stable filesystem identifier (same across all languages). +/// `category` is the primary category, invariant across languages. +/// `translation_slugs` maps language code → localized URL slug of the +/// translation. `translation_ids` maps language code → stable id of the +/// translation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostContentData { + pub title: String, + pub subtitle: String, + pub html: String, + #[serde(default)] + pub id: String, + #[serde(default)] + pub category: String, + #[serde(default)] + pub author: String, + #[serde(default)] + pub date: String, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub translation_slugs: std::collections::HashMap, + #[serde(default)] + pub translation_ids: std::collections::HashMap, +} + +/// Fetch rendered post content from the server (WASM only). +/// +/// HTTP 401 triggers the registered unauthorized handler (clear auth + redirect +/// to login) via `rustelo_core_lib::utils::fetch::on_unauthorized`. +#[cfg(target_arch = "wasm32")] +async fn fetch_post_content( + content_type: String, + language: String, + slug: String, +) -> Result { + let url = format!("/api/content-render/{}/{}/{}", content_type, language, slug); + let resp = rustelo_core_lib::utils::fetch::guarded_get(&url, "application/json").await?; + resp.json::() + .await + .map_err(|e| e.to_string()) +} + +/// Build a canonical post URL from the route config template (WASM only). +/// +/// Prefers `translation_slug` (localized URL slug for `target_lang`) over +/// the stable `id` so the resulting URL is fully localized. +/// Falls back to `id` when no localized slug is available. +#[cfg(target_arch = "wasm32")] +fn build_post_url_for_lang( + content_type: &str, + target_lang: &str, + category: &str, + id: &str, + translation_slug: Option<&str>, +) -> Option { + let slug_in_url = translation_slug.unwrap_or(id); + let config = rustelo_core_lib::routing::utils::load_routes_config(); + config + .routes + .iter() + .find(|r| { + r.get_enabled() + && r.get_language() == Some(target_lang.to_string()) + && r.content_type.as_deref() == Some(content_type) + && r.path.contains("{slug}") + }) + .map(|route| { + route + .path + .replace("{category}", category) + .replace("{slug}", slug_in_url) + }) +} + +/// Load post content synchronously on SSR for SEO. +/// +/// Returns the base i18n map extended with `post-title`, `post-subtitle`, +/// and `post-content` from the rendered markdown. On WASM this is never +/// called — the WASM path starts with the base map and updates asynchronously. +#[cfg(not(target_arch = "wasm32"))] +fn build_initial_content_ssr( + base: HashMap, + slug: &str, + content_type: &str, + language: &str, +) -> HashMap { + use rustelo_core_lib::fluent::{ + load_content_by_slug, process_markdown_file, rewrite_content_image_paths, + }; + + let mut content = base; + if slug.is_empty() { + return content; + } + let Ok(markdown) = load_content_by_slug(slug, content_type, language) else { + return content; + }; + let Ok((metadata, html)) = process_markdown_file(&markdown) else { + return content; + }; + let html = rewrite_content_image_paths(&html, content_type, language, slug); + let title = metadata.title.unwrap_or_else(|| { + slug.replace('-', " ") + .split_whitespace() + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => { + first.to_uppercase().collect::() + &chars.collect::() + } + } + }) + .collect::>() + .join(" ") + }); + content.insert("post-title".into(), title); + content.insert( + "post-subtitle".into(), + metadata.subtitle.unwrap_or_default(), + ); + content.insert("post-content".into(), html); + if let Some(author) = metadata.author { + content.insert("post-author".into(), author); + } + if let Some(date) = metadata.date { + content.insert("post-date".into(), date); + } + if !metadata.tags.is_empty() { + content.insert("post-tags".into(), metadata.tags.join(", ")); + } + content +} + +/// Unified PostViewer page component — SSR and WASM share the same structure. +/// +/// * **SSR**: loads markdown synchronously → full HTML in first paint (SEO) +/// * **WASM**: `Effect` + `spawn_local` fetch via `/api/content-render/…` +/// * Both sides emit the same `{move || …}` DynChild markers → hydration safe +#[component] +pub fn PostViewerPageClient( + #[prop(default = get_default_language().to_string())] _language: String, + #[prop(default = "".to_string())] slug: String, + #[prop(default = "content".to_string())] content_type: String, + #[prop(default = true)] show_author: bool, + #[prop(default = true)] show_date: bool, + #[prop(default = true)] show_tags: bool, + #[prop(default = false)] show_related_posts: bool, + #[prop(default = false)] enable_comments: bool, + #[prop(default = false)] enable_social_sharing: bool, +) -> impl IntoView { + let lang = use_language(); + let current_path = use_context::>(); + let _set_path = use_context::>(); + + // i18n strings (navigation labels, etc.) + let base_content = use_page_i18n_dynamic(vec!["post-viewer-"], || { + rustelo_core_lib::get_all_content_types() + .iter() + .map(|ct| format!("{}-", ct)) + .collect() + }); + + // Initial content: + // SSR — load markdown synchronously so search engines see real content. + // WASM — start with base i18n map; Effect below fills in post data async. + let initial = { + #[cfg(not(target_arch = "wasm32"))] + { + let lang_str = lang.current.get_untracked(); + build_initial_content_ssr( + base_content.get_untracked(), + &slug, + &content_type, + &lang_str, + ) + } + #[cfg(target_arch = "wasm32")] + { + base_content.get_untracked() + } + }; + + let merged_content = RwSignal::new(initial); + + // Stores the stable content id, primary category, and translation slugs from + // the last fetch. + let (post_id, set_post_id) = signal(String::new()); + let (_post_category, set_post_category) = signal(String::new()); + // translation_slugs: lang → localized URL slug in that language. + let (post_translation_slugs, set_post_translation_slugs) = + signal(std::collections::HashMap::::new()); + + let slug_prop = slug.clone(); + let ct = content_type.clone(); + + // WASM only: Effect re-runs whenever lang or path changes to fetch new content. + // SSR does NOT use an Effect — content is already loaded synchronously above, + // and Effect::new on SSR panics because no executor is available. + #[cfg(target_arch = "wasm32")] + { + Effect::new(move |_| { + let ctx_lang = lang.current.get(); + let raw_path = current_path.map(|p| p.get()).unwrap_or_default(); + let url_slug = if !raw_path.is_empty() { + PathResolver::extract_parameters(&raw_path) + .slug + .unwrap_or_else(|| slug_prop.clone()) + } else { + slug_prop.clone() + }; + + let stored_id = post_id.get_untracked(); + let fetch_slug = if !stored_id.is_empty() { + stored_id.clone() + } else { + url_slug.clone() + }; + + // Navigate to canonical URL on language switch. + if !stored_id.is_empty() && fetch_slug != url_slug { + let stored_category = _post_category.get_untracked(); + // Prefer the localized translation slug for the target language if available. + let t_slugs = post_translation_slugs.get_untracked(); + let localized_slug = t_slugs.get(&ctx_lang).map(|s| s.as_str()); + if let Some(new_url) = build_post_url_for_lang( + &ct, + &ctx_lang, + &stored_category, + &stored_id, + localized_slug, + ) { + if new_url != raw_path { + if let Some(sp) = _set_path { + rustelo_core_lib::utils::nav::anchor_navigate(sp, &new_url); + } + } + } + } + + let ct_c = ct.clone(); + let base = base_content.get_untracked(); + + leptos::task::spawn_local(async move { + match fetch_post_content(ct_c, ctx_lang, fetch_slug).await { + Ok(post) => { + let mut m = base; + m.insert("post-title".into(), post.title.clone()); + m.insert("post-subtitle".into(), post.subtitle.clone()); + m.insert("post-content".into(), post.html.clone()); + if !post.author.is_empty() { + m.insert("post-author".into(), post.author); + } + if !post.date.is_empty() { + m.insert("post-date".into(), post.date); + } + if !post.tags.is_empty() { + m.insert("post-tags".into(), post.tags.join(", ")); + } + if !post.id.is_empty() { + set_post_id.set(post.id.clone()); + } + if !post.category.is_empty() { + set_post_category.set(post.category); + } + if !post.translation_slugs.is_empty() { + set_post_translation_slugs.set(post.translation_slugs); + } + merged_content.set(m); + } + Err(e) => { + tracing::warn!("PostViewer fetch error: {}", e); + } + } + }); + }); + } + + // SSR: suppress unused warnings for WASM-only bindings. + #[cfg(not(target_arch = "wasm32"))] + let _ = ( + slug_prop, + ct, + post_id, + set_post_id, + _post_category, + set_post_category, + post_translation_slugs, + set_post_translation_slugs, + ); + + let slug_for_view = slug.clone(); + let ct_for_view = content_type.clone(); + + // DynChild (`{move || …}`) is the reactive gate for content updates. + // BOTH SSR and WASM emit ``-bounded DynChild → hydration cursor matches. + view! { + {move || { + let current_slug = current_path + .and_then(|p| PathResolver::extract_parameters(&p.get()).slug) + .unwrap_or_else(|| slug_for_view.clone()); + // SSR: get_untracked() — one-shot render, no reactive owner present. + // WASM: get() — tracked so the DynChild re-renders when content updates. + #[cfg(not(target_arch = "wasm32"))] + let content_map = merged_content.get_untracked(); + #[cfg(target_arch = "wasm32")] + let content_map = merged_content.get(); + view! { + + } + }} + } +} diff --git a/crates/foundation/crates/rustelo_pages/src/post_viewer/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/mod.rs similarity index 52% rename from crates/foundation/crates/rustelo_pages/src/post_viewer/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/mod.rs index 782f2e5..4f755db 100644 --- a/crates/foundation/crates/rustelo_pages/src/post_viewer/mod.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/mod.rs @@ -1,9 +1,5 @@ pub mod client; pub mod pages; +pub mod path_resolver; pub mod ssr; pub mod unified; - -// New modular components -pub mod content_loader; -pub mod html_generator; -pub mod path_resolver; diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/pages.rs b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/pages.rs new file mode 100644 index 0000000..cd2f5a1 --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/pages.rs @@ -0,0 +1,50 @@ +//! Unified PostViewer Page Component + +use leptos::prelude::*; +use rustelo_core_lib::config::get_default_language; + +#[component] +pub fn PostViewerPage( + #[prop(default = get_default_language().to_string())] _language: String, + #[prop(optional)] slug: Option, + #[prop(default = "content".to_string())] content_type: String, + #[prop(default = true)] show_author: bool, + #[prop(default = true)] show_date: bool, + #[prop(default = true)] show_tags: bool, + #[prop(default = false)] show_related_posts: bool, + #[prop(default = false)] enable_comments: bool, + #[prop(default = false)] enable_social_sharing: bool, +) -> impl IntoView { + #[cfg(not(target_arch = "wasm32"))] + { + view! { + + } + } + #[cfg(target_arch = "wasm32")] + { + view! { + + } + } +} diff --git a/crates/foundation/crates/rustelo_pages/src/post_viewer/path_resolver.rs b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/path_resolver.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/post_viewer/path_resolver.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/path_resolver.rs diff --git a/crates/foundation/crates/rustelo_pages/src/post_viewer/ssr.rs b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/ssr.rs similarity index 80% rename from crates/foundation/crates/rustelo_pages/src/post_viewer/ssr.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/ssr.rs index f414985..c0cd06f 100644 --- a/crates/foundation/crates/rustelo_pages/src/post_viewer/ssr.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/ssr.rs @@ -3,7 +3,7 @@ use leptos::prelude::*; use rustelo_core_lib::{ config::get_default_language, - fluent::{load_content_by_slug, process_markdown_file}, + fluent::{load_content_by_slug, process_markdown_file, rewrite_content_image_paths}, i18n::{build_page_content_patterns, SsrTranslator}, }; @@ -12,6 +12,12 @@ pub fn PostViewerPageSSR( #[prop(default = get_default_language().to_string())] _language: String, #[prop(default = "".to_string())] slug: String, #[prop(default = "content".to_string())] content_type: String, + #[prop(default = true)] show_author: bool, + #[prop(default = true)] show_date: bool, + #[prop(default = true)] show_tags: bool, + #[prop(default = false)] show_related_posts: bool, + #[prop(default = false)] enable_comments: bool, + #[prop(default = false)] enable_social_sharing: bool, ) -> impl IntoView { tracing::info!( "SSR: PostViewerPageSSR called with language: '{:?}', slug: {:?}, content_type: '{}'", @@ -77,13 +83,24 @@ pub fn PostViewerPageSSR( .join(" ") }); - // Set the post content content.insert("post-title".to_string(), title); content.insert( "post-subtitle".to_string(), metadata.subtitle.unwrap_or_default(), ); + let html = + rewrite_content_image_paths(&html, &content_type, &lang, &slug); content.insert("post-content".to_string(), html); + if let Some(author) = metadata.author { + content.insert("post-author".to_string(), author); + } + if let Some(date) = metadata.date { + content.insert("post-date".to_string(), date); + } + if !metadata.tags.is_empty() { + content + .insert("post-tags".to_string(), metadata.tags.join(", ")); + } tracing::info!( "SSR: Successfully loaded {} content: {}", @@ -131,6 +148,12 @@ pub fn PostViewerPageSSR( lang_content=content slug=slug content_type=content_type.clone() + show_author=show_author + show_date=show_date + show_tags=show_tags + show_related_posts=show_related_posts + enable_comments=enable_comments + enable_social_sharing=enable_social_sharing /> } } diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/unified.rs new file mode 100644 index 0000000..4f7417c --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/post_viewer/unified.rs @@ -0,0 +1,145 @@ +//! Unified PostViewer Page Component + +use leptos::prelude::*; +use rustelo_core_lib::i18n::content_helper::create_content_provider_reactive; + +#[component] +pub fn PostViewerPage( + _language: String, + #[prop(optional)] slug: Option, + #[prop(default = "content".to_string())] content_type: String, +) -> impl IntoView { + #[cfg(not(target_arch = "wasm32"))] + { + view! { } + } + #[cfg(target_arch = "wasm32")] + { + view! { } + } +} + +#[component] +pub fn UnifiedPostViewerPage( + #[allow(unused_variables)] + #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] + _language: String, + #[prop(optional)] lang_content: Option>, + #[allow(unused_variables)] + #[prop(default = "content".to_string())] + content_type: String, + #[allow(unused_variables)] + #[prop(default = "".to_string())] + slug: String, + #[prop(default = true)] show_author: bool, + #[prop(default = true)] show_date: bool, + #[prop(default = true)] show_tags: bool, + /// Accepted for API compatibility — no related-posts system is implemented. + #[allow(unused_variables)] + #[prop(default = false)] + show_related_posts: bool, + /// Accepted for API compatibility — no comments system is implemented. + #[allow(unused_variables)] + #[prop(default = false)] + enable_comments: bool, + /// Accepted for API compatibility — no social-sharing system is + /// implemented. + #[allow(unused_variables)] + #[prop(default = false)] + enable_social_sharing: bool, +) -> impl IntoView { + let content = create_content_provider_reactive(lang_content.clone()); + + let post_title = content.t_quiet("post-title"); + let post_subtitle = content.t_quiet("post-subtitle"); + let post_content = content.t_quiet("post-content"); + let post_author = content.t_quiet("post-author"); + let post_date = content.t_quiet("post-date"); + let post_tags_raw = content.t_quiet("post-tags"); + + let has_content = !post_title.is_empty() || !post_content.is_empty(); + let has_author = show_author && !post_author.is_empty(); + let has_date = show_date && !post_date.is_empty(); + let has_tags = show_tags && !post_tags_raw.is_empty(); + let tags: Vec = if has_tags { + post_tags_raw.split(", ").map(str::to_string).collect() + } else { + vec![] + }; + + view! { +
+
+
+

+ {post_title} +

+
+ {post_subtitle} +
+ + // ── Byline: author + date ───────────────────────────────────────── + // Always render the container so SSR and WASM produce identical DOM + // structure. CSS `hidden` controls visibility instead of Option + // (.then(|| view!{...})), which emits `` when None and causes a + // hydration panic when WASM starts with empty lang_content. +
+ + {post_author.clone()} + + "·" + +
+ + // ── Tags ───────────────────────────────────────────────────────── + // inner_html avoids the Vec count mismatch: SSR emits N + // children, WASM initial renders 0. Both now emit a single
+ // element whose inner content is opaque to the hydration cursor. +
{}", + tag.replace('&', "&").replace('<', "<").replace('>', ">") + )) + .collect::() + } + >
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+ "Loading..." +
+
+
+
+ } +} diff --git a/crates/foundation/crates/rustelo_pages/src/privacy/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/privacy/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/privacy/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/privacy/mod.rs diff --git a/crates/foundation/crates/rustelo_pages/src/privacy/pages.rs b/crates/foundation/crates/rustelo_pages_leptos/src/privacy/pages.rs similarity index 85% rename from crates/foundation/crates/rustelo_pages/src/privacy/pages.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/privacy/pages.rs index 6a73681..653d070 100644 --- a/crates/foundation/crates/rustelo_pages/src/privacy/pages.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/privacy/pages.rs @@ -11,16 +11,13 @@ pub fn PrivacyClient( // Use the existing use_page_i18n_dynamic function let page_content = crate::common::use_page_i18n_dynamic(vec!["privacy-", "legal-"], || vec![]); - // Clone language for use in closure - let language = _language.clone(); + let lang_content = page_content.get_untracked(); - move || { - view! { - - } + view! { + } } diff --git a/crates/foundation/crates/rustelo_pages/src/privacy/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/privacy/unified.rs similarity index 98% rename from crates/foundation/crates/rustelo_pages/src/privacy/unified.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/privacy/unified.rs index f65f7ac..99fdb6c 100644 --- a/crates/foundation/crates/rustelo_pages/src/privacy/unified.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/privacy/unified.rs @@ -1,7 +1,7 @@ //! Unified Privacy Page Component use leptos::prelude::*; -use rustelo_core_lib::i18n::content_helper::create_content_provider; +use rustelo_core_lib::i18n::content_helper::create_content_provider_reactive; #[component] pub fn UnifiedPrivacyPage( @@ -11,7 +11,7 @@ pub fn UnifiedPrivacyPage( ) -> impl IntoView { // Clean closure pattern that works with Leptos ownership model // Use DRY content accessor helper - let content = create_content_provider(lang_content.clone()); + let content = create_content_provider_reactive(lang_content.clone()); view! {
diff --git a/crates/foundation/crates/rustelo_pages/src/user/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/signout/mod.rs similarity index 100% rename from crates/foundation/crates/rustelo_pages/src/user/mod.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/signout/mod.rs diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/signout/pages.rs b/crates/foundation/crates/rustelo_pages_leptos/src/signout/pages.rs new file mode 100644 index 0000000..e415954 --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/signout/pages.rs @@ -0,0 +1,47 @@ +use leptos::prelude::*; + +use super::unified::UnifiedSignoutPage; + +#[component] +pub fn SignoutClient( + #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] + language: String, +) -> impl IntoView { + let page_content = crate::common::use_page_i18n_dynamic(vec!["signout-"], || vec![]); + let lang_content = page_content.get_untracked(); + + view! { + + } +} + +#[component] +pub fn SignoutSSR( + #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] + language: String, +) -> impl IntoView { + let translator = rustelo_core_lib::i18n::page_translator::SsrTranslator::new(language.clone()); + let patterns: Vec<&str> = vec!["signout-"]; + let lang_content = + rustelo_core_lib::i18n::helpers::build_page_content_patterns(&translator, &patterns); + + view! { + + } +} + +#[component] +pub fn SignoutPage( + #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] + language: String, +) -> impl IntoView { + #[cfg(not(target_arch = "wasm32"))] + { + view! { } + } + + #[cfg(target_arch = "wasm32")] + { + view! { } + } +} diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/signout/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/signout/unified.rs new file mode 100644 index 0000000..cd103f6 --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/signout/unified.rs @@ -0,0 +1,99 @@ +use leptos::prelude::*; +use rustelo_core_lib::i18n::content_helper::create_content_provider_reactive; + +/// Async sleep helper (WASM-only; SSR countdown is never actually ticked). +#[cfg(target_arch = "wasm32")] +async fn sleep_secs(secs: u32) { + gloo_timers::future::sleep(std::time::Duration::from_secs(u64::from(secs))).await; +} + +/// Signout goodbye page. +/// +/// SSR: renders a generic goodbye (no name, no countdown). +/// WASM: reads `goodbye_name` from `sessionStorage`, personalises the title, +/// and starts a 10-second countdown that redirects to `/`. +#[component] +pub fn UnifiedSignoutPage( + #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] + _language: String, + #[prop(optional)] lang_content: Option>, +) -> impl IntoView { + let content = create_content_provider_reactive(lang_content); + + // Reactive title — SSR stays generic; WASM may update it after reading + // sessionStorage. + let title = RwSignal::new(content.t("signout-title")); + let countdown = RwSignal::new(10u32); + let home_path = { + let lang = _language.clone(); + if lang == "es" { + "/inicio".to_string() + } else { + "/".to_string() + } + }; + + #[cfg(target_arch = "wasm32")] + { + let content_clone = content.clone(); + let home = home_path.clone(); + let set_path = use_context::>(); + leptos::task::spawn_local(async move { + // Personalise greeting from sessionStorage. + let goodbye_name = web_sys::window() + .and_then(|w| w.session_storage().ok().flatten()) + .and_then(|s| s.get_item("goodbye_name").ok().flatten()); + + let t = match goodbye_name { + Some(ref name) if !name.is_empty() => content_clone + .t("signout-title-with-name") + .replace("$name", name), + _ => content_clone.t("signout-title"), + }; + title.set(t); + + // 10-second countdown then navigate. + let mut remaining = 10u32; + while remaining > 0 { + sleep_secs(1).await; + remaining = remaining.saturating_sub(1); + countdown.set(remaining); + } + if let Some(sp) = set_path { + sp.set(home); + } else if let Some(win) = web_sys::window() { + let _ = win.location().set_href(&home); + } + }); + } + + let home = home_path.clone(); + let redirect_label = { + let c = content.clone(); + move || { + c.t("signout-redirect") + .replace("$seconds", &countdown.get().to_string()) + } + }; + + view! { +
+
+
"👋"
+

{move || title.get()}

+

{content.t("signout-subtitle")}

+ + + +

{redirect_label}

+
+
+ } +} diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/user/mod.rs b/crates/foundation/crates/rustelo_pages_leptos/src/user/mod.rs new file mode 100644 index 0000000..43ccfcc --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/user/mod.rs @@ -0,0 +1,2 @@ +pub mod pages; +pub mod unified; diff --git a/crates/foundation/crates/rustelo_pages/src/user/pages.rs b/crates/foundation/crates/rustelo_pages_leptos/src/user/pages.rs similarity index 70% rename from crates/foundation/crates/rustelo_pages/src/user/pages.rs rename to crates/foundation/crates/rustelo_pages_leptos/src/user/pages.rs index d893f0a..c7302bf 100644 --- a/crates/foundation/crates/rustelo_pages/src/user/pages.rs +++ b/crates/foundation/crates/rustelo_pages_leptos/src/user/pages.rs @@ -6,21 +6,18 @@ use super::unified::UnifiedUserPage; #[component] pub fn UserClient( #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] - _language: String, + language: String, ) -> impl IntoView { // Use the existing use_page_i18n_dynamic function let page_content = crate::common::use_page_i18n_dynamic(vec!["user-", "profile-"], || vec![]); - // Clone language for use in closure - let language = _language.clone(); + let lang_content = page_content.get_untracked(); - move || { - view! { - - } + view! { + } } @@ -28,17 +25,17 @@ pub fn UserClient( #[component] pub fn UserSSR( #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] - _language: String, + language: String, ) -> impl IntoView { // Create static translator for SSR - let translator = rustelo_core_lib::i18n::page_translator::SsrTranslator::new(_language.clone()); + let translator = rustelo_core_lib::i18n::page_translator::SsrTranslator::new(language.clone()); let patterns: Vec<&str> = vec!["user-", "profile-"]; let lang_content = rustelo_core_lib::i18n::helpers::build_page_content_patterns(&translator, &patterns); view! { } @@ -48,15 +45,15 @@ pub fn UserSSR( #[component] pub fn UserPage( #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] - _language: String, + language: String, ) -> impl IntoView { #[cfg(not(target_arch = "wasm32"))] { - view! { } + view! { } } #[cfg(target_arch = "wasm32")] { - view! { } + view! { } } } diff --git a/crates/foundation/crates/rustelo_pages_leptos/src/user/unified.rs b/crates/foundation/crates/rustelo_pages_leptos/src/user/unified.rs new file mode 100644 index 0000000..7da6bec --- /dev/null +++ b/crates/foundation/crates/rustelo_pages_leptos/src/user/unified.rs @@ -0,0 +1,964 @@ +use leptos::prelude::*; +use leptos::task::spawn_local; +use rustelo_components_leptos::auth::{ + server_create_note, server_delete_account, server_delete_note, server_get_bookmarks, + server_get_messages, server_get_notes, server_get_resources, server_mark_message_read, + server_remove_bookmark, server_update_display_name, server_update_note, BookmarkInfo, + UserMessage, UserNote, UserResource, +}; +#[cfg(target_arch = "wasm32")] +use rustelo_components_leptos::toast::{push_toast_to, ToastKind}; +#[cfg(target_arch = "wasm32")] +use rustelo_core_lib::utils::nav; +use rustelo_core_lib::{i18n::content_helper::create_content_provider_reactive, state::AuthState}; + +#[derive(Copy, Clone, PartialEq)] +enum ProfileTab { + Perfil, + Marcadores, + Mensajes, + Notas, + Recursos, +} + +#[component] +pub fn UnifiedUserPage( + #[prop(default = rustelo_core_lib::config::get_default_language().to_string())] + _language: String, + #[prop(optional)] lang_content: Option>, +) -> impl IntoView { + let content = create_content_provider_reactive(lang_content); + let auth_state = use_context::(); + + // WASM: redirect unauthenticated visitors to login. + #[cfg(target_arch = "wasm32")] + { + use rustelo_core_lib::state::use_current_language; + let set_path = use_context::>(); + let lang_signal = use_current_language(); + let auth = auth_state.clone(); + Effect::new(move |_| { + let authenticated = auth + .as_ref() + .map(|a| a.authenticated.get()) + .unwrap_or(false); + if !authenticated { + let lang = lang_signal.get_untracked(); + let (login_path, return_path) = if lang == "es" { + ("/iniciar-sesion", "/usuario") + } else { + ("/login", "/user") + }; + let target = format!( + "{}?return={}", + login_path, + js_sys::encode_uri_component(return_path) + ); + if let Some(sp) = set_path { + sp.set(target); + } + } + }); + } + + // SSR: redirect unauthenticated visitors to NotFound (sets HTTP 404). + #[cfg(feature = "ssr")] + { + let is_authed = auth_state + .as_ref() + .map(|a| a.authenticated.get_untracked()) + .unwrap_or(false); + if !is_authed { + if let Some(resp) = use_context::() { + resp.set_status(axum::http::StatusCode::NOT_FOUND); + } + let lang = _language.clone(); + return view! { + + } + .into_any(); + } + } + + let _ = _language; + + // ── Reactive auth helpers — Copy signals, safe to capture in many move || ── + let auth_authenticated: Option> = auth_state.as_ref().map(|a| a.authenticated); + + // ── Active tab state ─────────────────────────────────────────────────── + let active_tab: RwSignal = RwSignal::new(ProfileTab::Perfil); + + // ── Name-edit state ──────────────────────────────────────────────────── + let user_signal: Option>> = + auth_state.as_ref().map(|a| a.user); + + let current_name_memo = Memo::new(move |_| { + user_signal + .and_then(|sig| { + #[cfg(target_arch = "wasm32")] + { + sig.get() + } + #[cfg(not(target_arch = "wasm32"))] + { + sig.get_untracked() + } + }) + .and_then(|u| u.name) + .unwrap_or_default() + }); + + let editing_name: RwSignal = RwSignal::new(false); + let name_input: RwSignal = RwSignal::new(String::new()); + let name_save_error: RwSignal> = RwSignal::new(None); + let name_save_ok: RwSignal = RwSignal::new(false); + + // ── Delete account state ─────────────────────────────────────────────── + let delete_error: RwSignal> = RwSignal::new(None); + let confirm_delete: RwSignal = RwSignal::new(false); + // Copy signals to inline clear_auth() without capturing non-Copy + // Option + let auth_permissions_signal: Option>> = + auth_state.as_ref().map(|a| a.permissions); + let auth_error_signal: Option>> = auth_state.as_ref().map(|a| a.error); + let set_path_delete = use_context::>(); + + // ── Data resources — each Resource gets its own move || capturing the Copy + // signal ── + let bookmarks = Resource::new( + move || auth_authenticated.map(|s| s.get()).unwrap_or(false), + |authenticated| async move { + if authenticated { + server_get_bookmarks().await + } else { + Ok(vec![]) + } + }, + ); + + let messages = Resource::new( + move || auth_authenticated.map(|s| s.get()).unwrap_or(false), + |authenticated| async move { + if authenticated { + server_get_messages().await + } else { + Ok(vec![]) + } + }, + ); + + let notes = Resource::new( + move || auth_authenticated.map(|s| s.get()).unwrap_or(false), + |authenticated| async move { + if authenticated { + server_get_notes().await + } else { + Ok(vec![]) + } + }, + ); + + let resources = Resource::new( + move || auth_authenticated.map(|s| s.get()).unwrap_or(false), + |authenticated| async move { + if authenticated { + server_get_resources().await + } else { + Ok(vec![]) + } + }, + ); + + // ── Notes editing state ──────────────────────────────────────────────── + let editing_note: RwSignal> = RwSignal::new(None); + let note_title: RwSignal = RwSignal::new(String::new()); + let note_body: RwSignal = RwSignal::new(String::new()); + let note_save_ok: RwSignal = RwSignal::new(false); + let creating_note: RwSignal = RwSignal::new(false); + + // Reset note editing state when leaving Notas tab. + Effect::new(move |_| { + let tab = { + #[cfg(target_arch = "wasm32")] + { + active_tab.get() + } + #[cfg(not(target_arch = "wasm32"))] + { + active_tab.get_untracked() + } + }; + if tab != ProfileTab::Notas { + editing_note.set(None); + creating_note.set(false); + note_save_ok.set(false); + } + }); + + // ── Transactional toast feedback ─────────────────────────────────────── + #[cfg(target_arch = "wasm32")] + { + // Capture toast signal once (Copy) so Effects can call push_toast_to. + let ts = + use_context::>>(); + Effect::new(move |_| { + if note_save_ok.get() { + if let Some(sig) = ts { + push_toast_to(sig, ToastKind::Info, "Note saved"); + } + // Auto-clear — prevent re-trigger on reactive re-runs. + note_save_ok.set(false); + } + }); + } + + // ── Unread count badge ───────────────────────────────────────────────── + let unread_count = Memo::new(move |_| { + messages + .get() + .and_then(|r| r.ok()) + .map(|msgs| msgs.iter().filter(|m: &&UserMessage| !m.is_read).count()) + .unwrap_or(0) + }); + + // ── Pre-computed labels ──────────────────────────────────────────────── + // Tab buttons + let lbl_tab_profile = content.t("user-tab-profile"); + let lbl_tab_bookmarks = content.t("user-tab-bookmarks"); + let lbl_tab_messages = content.t("user-tab-messages"); + let lbl_tab_notes = content.t("user-tab-notes"); + let lbl_tab_resources = content.t("user-tab-resources"); + // Perfil tab + let lbl_profile_info = content.t("user-profile-info"); + let lbl_name = content.t("user-name"); + let lbl_save_changes = content.t("user-save-changes"); + let lbl_cancel = content.t("user-cancel"); + let lbl_edit = content.t("user-edit"); + let lbl_profile_updated = content.t("user-profile-updated"); + let lbl_email = content.t("user-email"); + let lbl_role = content.t("user-role"); + let lbl_loading = content.t("user-loading"); + let lbl_danger_zone = content.t("user-danger-zone"); + let lbl_acct_security = content.t("user-account-security"); + let lbl_try_again = content.t("user-please-try-again"); + let lbl_delete = content.t("user-delete"); + let lbl_delete_account = content.t("user-delete-account"); + // Bookmarks tab + let lbl_bm_empty = content.t("user-bookmarks-empty"); + // Messages tab + let lbl_msg_empty = content.t("user-messages-empty"); + let lbl_msg_subject = content.t("user-messages-subject"); + let lbl_msg_from = content.t("user-messages-from"); + let lbl_msg_mark_read = content.t("user-messages-mark-read"); + let lbl_msg_unread = content.t("user-messages-unread"); + // Notes tab + let lbl_notes_empty = content.t("user-notes-empty"); + let lbl_notes_create = content.t("user-notes-create"); + let lbl_notes_title_ph = content.t("user-notes-title-ph"); + let lbl_notes_body_ph = content.t("user-notes-body-ph"); + let lbl_notes_save = content.t("user-notes-save"); + let lbl_notes_delete = content.t("user-notes-delete"); + let lbl_notes_saved = content.t("user-notes-saved"); + // Resources tab + let lbl_res_empty = content.t("user-resources-empty"); + let lbl_res_open = content.t("user-resources-open"); + // Section headings — sidebar closure takes the originals; content panel gets + // clones. + let lbl_section_bookmarks = lbl_tab_bookmarks.clone(); + let lbl_section_messages = lbl_tab_messages.clone(); + let lbl_section_notes = lbl_tab_notes.clone(); + let lbl_section_resources = lbl_tab_resources.clone(); + + view! { +
+
+
+ + // ── Sidebar navigation ──────────────────────────────────── + + + // ── Content panel ───────────────────────────────────────── +
+ {move || { + let tab = { + #[cfg(target_arch = "wasm32")] { active_tab.get() } + #[cfg(not(target_arch = "wasm32"))] { active_tab.get_untracked() } + }; + match tab { + ProfileTab::Perfil => { + // ── Perfil tab ───────────────────────────── + let lpi = lbl_profile_info.clone(); + let lnm = lbl_name.clone(); + let lsc = lbl_save_changes.clone(); + let lca = lbl_cancel.clone(); + let lca_dz = lbl_cancel.clone(); // for danger zone cancel + let led = lbl_edit.clone(); + let lpu = lbl_profile_updated.clone(); + let lem = lbl_email.clone(); + let lrl = lbl_role.clone(); + let llo = lbl_loading.clone(); + let ldz = lbl_danger_zone.clone(); + let las = lbl_acct_security.clone(); + let lta = lbl_try_again.clone(); + let lde = lbl_delete.clone(); + let lda = lbl_delete_account.clone(); + view! { +
+ // Profile card + {move || { + let is_authed = { + #[cfg(target_arch = "wasm32")] + { auth_authenticated.map(|s| s.get()).unwrap_or(false) } + #[cfg(not(target_arch = "wasm32"))] + { auth_authenticated.map(|s| s.get_untracked()).unwrap_or(false) } + }; + if is_authed { + match user_signal.and_then(|s| { + #[cfg(target_arch = "wasm32")] { s.get() } + #[cfg(not(target_arch = "wasm32"))] { s.get_untracked() } + }) { + Some(u) => { + let email = u.email.clone(); + let roles = u.roles.clone(); + let save_c = lsc.clone(); + let cancel = lca.clone(); + let edit_b = led.clone(); + let prof_u = lpu.clone(); + view! { +
+

+ {lpi.clone()} +

+
+ // Name row (editable) +
+
{lnm.clone()}
+
+ {move || { + let current = current_name_memo.get(); + if editing_name.get() { + let sv = save_c.clone(); + let ca = cancel.clone(); + view! { +
+ + + +
+ }.into_any() + } else { + let display = if current.is_empty() { "—".to_string() } else { current.clone() }; + let eb = edit_b.clone(); + view! { +
+ {display} + +
+ }.into_any() + } + }} +
+
+ // Feedback + {move || name_save_error.get().map(|e| view! {

{e}

})} + {move || name_save_ok.get().then(|| view! {

{prof_u.clone()}

})} + // Email row +
+
{lem.clone()}
+
{email}
+
+ // Role row +
+
{lrl.clone()}
+
+ {roles.into_iter().map(|r| view! { + {r} + }).collect::>()} +
+
+
+
+ }.into_any() + } + None => view! { +
+

{llo.clone()}

+
+ }.into_any(), + } + } else { + view! {
}.into_any() + } + }} + // Danger zone (bottom of Perfil tab) +
+

{ldz}

+

{las}

+ {move || { + #[cfg(target_arch = "wasm32")] + let err = delete_error.get(); + #[cfg(not(target_arch = "wasm32"))] + let err = delete_error.get_untracked(); + err.map(|e| view! {

{e}

}) + }} + {move || { + #[cfg(target_arch = "wasm32")] + let confirmed = confirm_delete.get(); + #[cfg(not(target_arch = "wasm32"))] + let confirmed = confirm_delete.get_untracked(); + if confirmed { + let sp_del = set_path_delete; + let lta2 = lta.clone(); + let lde2 = lde.clone(); + view! { +
+ {lta2} + + +
+ }.into_any() + } else { + let lda2 = lda.clone(); + view! { + + }.into_any() + } + }} +
+
+ }.into_any() + } + + ProfileTab::Marcadores => { + // ── Marcadores tab ───────────────────────── + let lbe = lbl_bm_empty.clone(); + let lde = lbl_delete.clone(); + let llo = lbl_loading.clone(); + view! { +
+

{lbl_section_bookmarks.clone()}

+ {llo.clone()}

+ }> + {move || bookmarks.get().map(|result| { + let lbe2 = lbe.clone(); + let lde2 = lde.clone(); + match result { + Err(e) => view! { +

{e.to_string()}

+ }.into_any(), + Ok(items) if items.is_empty() => view! { +

{lbe2}

+ }.into_any(), + Ok(items) => { + let rows = items.into_iter().map(|bm| { + let BookmarkInfo { id, content_title, content_url, .. } = bm; + let bm_id = id.clone(); + let href = content_url.as_ref().filter(|u| !u.is_empty()).cloned(); + let display = content_title + .or_else(|| content_url.clone()) + .unwrap_or_else(|| id.clone()); + let lde3 = lde2.clone(); + let remove = move |_| { + let id2 = bm_id.clone(); + spawn_local(async move { + let _ = server_remove_bookmark(id2).await; + bookmarks.refetch(); + }); + }; + let display_node = if let Some(url) = href { + #[allow(unused_variables)] + let url_nav = url.clone(); + #[allow(unused_variables)] + let url_copy = url.clone(); + let on_nav = move |_ev: leptos::ev::MouseEvent| { + #[cfg(target_arch = "wasm32")] + { + if let Some(set_path) = use_context::>() { + _ev.prevent_default(); + nav::anchor_navigate(set_path, &url_nav); + rustelo_core_lib::utils::doc_scroll_to_top(100); + } + } + }; + let on_copy = move |_ev: leptos::ev::MouseEvent| { + #[cfg(target_arch = "wasm32")] + { + let u = url_copy.clone(); + spawn_local(async move { + if let Some(w) = web_sys::window() { + let full_url = if u.starts_with('/') { + w.location().origin().ok().map(|o| format!("{o}{u}")).unwrap_or(u) + } else { u }; + let _ = wasm_bindgen_futures::JsFuture::from( + w.navigator().clipboard().write_text(&full_url), + ).await; + } + }); + } + }; + view! { +
+ {display} + +
+ }.into_any() + } else { + view! { {display} }.into_any() + }; + view! { +
  • + {display_node} + +
  • + } + }).collect::>(); + view! {
      {rows}
    }.into_any() + } + } + })} +
    +
    + }.into_any() + } + + ProfileTab::Mensajes => { + // ── Mensajes tab ─────────────────────────── + let lme = lbl_msg_empty.clone(); + let lms = lbl_msg_subject.clone(); + let lmf = lbl_msg_from.clone(); + let lmr = lbl_msg_mark_read.clone(); + let lmu = lbl_msg_unread.clone(); + let llo = lbl_loading.clone(); + view! { +
    +

    {lbl_section_messages.clone()}

    + {llo.clone()}

    + }> + {move || messages.get().map(|result| { + let lme2 = lme.clone(); + let lms2 = lms.clone(); + let lmf2 = lmf.clone(); + let lmr2 = lmr.clone(); + let lmu2 = lmu.clone(); + match result { + Err(e) => view! { +

    {e.to_string()}

    + }.into_any(), + Ok(msgs) if msgs.is_empty() => view! { +

    {lme2}

    + }.into_any(), + Ok(msgs) => { + let rows = msgs.into_iter().map(|msg: UserMessage| { + let msg_id = msg.id.clone(); + let is_read = msg.is_read; + let subject = msg.subject.clone(); + let from = msg.sender_email.clone(); + let lmr3 = lmr2.clone(); + let lmu3 = lmu2.clone(); + let lms3 = lms2.clone(); + let lmf3 = lmf2.clone(); + view! { +
  • +
    +
    + {lmf3}: {from} + {lms3}: {subject} +
    + {(!is_read).then(|| { + let mid = msg_id.clone(); + let lmr4 = lmr3.clone(); + view! { +
    + {lmu3} + +
    + } + })} +
    +

    {msg.body}

    +
  • + } + }).collect::>(); + view! {
      {rows}
    }.into_any() + } + } + })} +
    +
    + }.into_any() + } + + ProfileTab::Notas => { + // ── Notas tab ────────────────────────────── + let lne = lbl_notes_empty.clone(); + let lnc = lbl_notes_create.clone(); + let lnt = lbl_notes_title_ph.clone(); + let lnb = lbl_notes_body_ph.clone(); + let lns = lbl_notes_save.clone(); + let lnd = lbl_notes_delete.clone(); + let lnk = lbl_notes_saved.clone(); + let llo = lbl_loading.clone(); + let lca_notas = lbl_cancel.clone(); // local clone for inner reactive + let led_notas = lbl_edit.clone(); // local clone for note list + view! { +
    +
    +

    {lbl_section_notes.clone()}

    + {move || { + let creating = { + #[cfg(target_arch = "wasm32")] { creating_note.get() } + #[cfg(not(target_arch = "wasm32"))] { creating_note.get_untracked() } + }; + let lnc2 = lnc.clone(); + if !creating { + view! { + + }.into_any() + } else { + view! { }.into_any() + } + }} +
    + + // Create / edit form + {move || { + let show_form = { + #[cfg(target_arch = "wasm32")] { + creating_note.get() || editing_note.get().is_some() + } + #[cfg(not(target_arch = "wasm32"))] { + creating_note.get_untracked() || editing_note.get_untracked().is_some() + } + }; + if show_form { + let lnt2 = lnt.clone(); + let lnb2 = lnb.clone(); + let lns2 = lns.clone(); + let lca2 = lca_notas.clone(); + let lnk2 = lnk.clone(); + view! { +
    + +
    "## + ) + } + + fn thanks_fragment(lang: &str) -> String { + let msg = esc(t( + lang, + "Thanks — your message was sent.", + "Gracias — tu mensaje se ha enviado.", + )); + let l_close = t(lang, "Close", "Cerrar"); + format!( + r##"

    {msg}

    "## + ) + } + + fn error_fragment(message: &str) -> String { + format!( + r#""#, + esc(message) + ) + } + + fn internal_error() -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(error_fragment("Something went wrong. Please try again.")), + ) + .into_response() + } + + fn too_many_requests() -> Response { + ( + StatusCode::TOO_MANY_REQUESTS, + Html(error_fragment("Too many requests — please slow down.")), + ) + .into_response() + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /// Restrict a language code to a short, safe token; default `en`. + fn normalize_lang(lang: Option<&str>) -> String { + match lang { + Some(l) if l.len() <= 8 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => { + l.to_string() + } + _ => "en".to_string(), + } + } + + /// Pick the English or Spanish string for `lang`. Bilingual fragments avoid + /// a `.ftl` round-trip for the handful of strings these endpoints emit. + fn t<'a>(lang: &str, en: &'a str, es: &'a str) -> &'a str { + if lang == "es" { + es + } else { + en + } + } + + /// Minimal HTML-attribute/text escaping. Kept local so the + /// `post-engagement` feature carries no extra dependency. + fn esc(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out + } + + #[cfg(test)] + mod tests { + use super::*; + + fn headers_with(cookie: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::COOKIE, + HeaderValue::from_str(cookie).expect("valid cookie header"), + ); + h + } + + #[test] + fn resolve_visitor_reuses_existing_cookie() { + let h = headers_with("vid=abc123; theme=dark"); + let (id, fresh) = resolve_visitor(&h); + assert_eq!(id, "abc123"); + assert!(!fresh); + } + + #[test] + fn resolve_visitor_mints_when_absent() { + let (id, fresh) = resolve_visitor(&HeaderMap::new()); + assert!(fresh); + assert!(Uuid::parse_str(&id).is_ok()); + } + + #[test] + fn stars_fragment_marks_own_vote_and_targets_rating() { + let summary = RatingSummary { + average: 4.0, + count: 3, + }; + let html = stars_fragment("my-post", &summary, Some(4), "en"); + assert!(html.contains(r#"id="post-rating""#)); + assert!(html.contains("pe-star-mine")); + assert!(html.contains(r#"hx-post="/api/htmx/post/my-post/rate""#)); + assert!(html.contains("3 votes")); // aggregate shown when count > 0 + assert_eq!(html.matches(""#, "en"); + assert!(html.contains(""quoted"")); + assert!(html.contains("<title>")); + assert!(html.contains(r#"name="website""#)); // honeypot present + } + + #[test] + fn normalize_lang_rejects_junk() { + assert_eq!(normalize_lang(Some("en")), "en"); + assert_eq!(normalize_lang(Some("es-ES")), "es-ES"); + assert_eq!(normalize_lang(Some("../etc")), "en"); + assert_eq!(normalize_lang(None), "en"); + } + } +} + +#[cfg(feature = "post-engagement")] +pub use imp::htmx_post_routes; diff --git a/crates/foundation/crates/rustelo_server/src/api/htmx_sse.rs b/crates/foundation/crates/rustelo_server/src/api/htmx_sse.rs new file mode 100644 index 0000000..2bba63d --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/api/htmx_sse.rs @@ -0,0 +1,214 @@ +//! Server-Sent Events broadcaster for HTMX toasts and live notifications. +//! +//! Mounts `GET /api/htmx/notifications/sse`. Each connection subscribes to a +//! process-wide [`tokio::sync::broadcast`] channel and forwards every +//! [`ToastEvent`] to the client as a named SSE event. +//! +//! Other modules publish toasts via [`publish_toast`]. The htmx-ext-sse +//! extension wires the client side: the toast container element carries +//! `hx-ext="sse" sse-connect="/api/htmx/notifications/sse" sse-swap="toast"`, +//! so each event named `toast` lands in the container with the swap kind the +//! element declares (`beforeend` for stacking). + +use std::convert::Infallible; +use std::sync::OnceLock; +use std::time::Duration; + +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; +use futures::stream::{self, Stream, StreamExt}; +use html_escape::{encode_double_quoted_attribute, encode_text}; +use tokio::sync::broadcast::{self, Receiver, Sender}; +use tokio_stream::wrappers::BroadcastStream; + +/// A toast carries the user-visible text + a level controlling the badge +/// colour. Kept tiny on purpose: SSE traffic shouldn't ship structured logs. +#[derive(Debug, Clone)] +pub struct ToastEvent { + pub level: ToastLevel, + pub message: String, + /// Optional content-graph-shim style id for keyed swaps; defaults to + /// a timestamp-derived value at render time when None. + pub id: Option, +} + +#[derive(Debug, Clone, Copy)] +pub enum ToastLevel { + Info, + Success, + Warning, + Error, +} + +impl ToastLevel { + fn as_str(self) -> &'static str { + match self { + ToastLevel::Info => "info", + ToastLevel::Success => "success", + ToastLevel::Warning => "warning", + ToastLevel::Error => "error", + } + } +} + +/// Channel capacity. Tuned to absorb bursts (login flood, content publishes) +/// without disconnecting subscribers; old messages are dropped when full. +const CHANNEL_CAPACITY: usize = 64; + +static TOAST_BUS: OnceLock> = OnceLock::new(); + +fn bus() -> &'static Sender { + TOAST_BUS.get_or_init(|| { + let (tx, _) = broadcast::channel(CHANNEL_CAPACITY); + tx + }) +} + +/// Publish a toast to every connected SSE subscriber. Returns the number of +/// active subscribers when successful, or 0 when no clients are connected. +pub fn publish_toast(event: ToastEvent) -> usize { + bus().send(event).unwrap_or(0) +} + +/// Mount the SSE endpoint. +pub fn htmx_sse_routes() -> Router { + Router::new().route("/sse", get(sse_handler)) +} + +async fn sse_handler() -> impl IntoResponse { + let rx: Receiver = bus().subscribe(); + let live = BroadcastStream::new(rx).filter_map(|res| async { + match res { + Ok(event) => Some(Ok::<_, Infallible>(toast_to_sse_event(&event))), + // BroadcastStream::Lagged: receiver fell behind. Drop the lag + // notification (don't kill the connection); the next live event + // resyncs. + Err(_) => None, + } + }); + // Emit an immediate comment event on connect. Without this, browsers + // see a 200 OK + Content-Type but zero body for `keep_alive.interval` + // seconds; some heuristics (and many reverse proxies) consider the + // connection dead and EventSource closes + retries, producing the + // accumulating-retry-loop bug. + let hello = stream::once(async { Ok::<_, Infallible>(Event::default().comment("connected")) }); + let combined = hello.chain(live); + + // Keep-alive every 5s. Tight enough to defeat aggressive browser/proxy + // idle heuristics; lax enough to not pollute logs. + Sse::new(combined).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(5)) + .text("keep-alive"), + ) +} + +fn toast_to_sse_event(event: &ToastEvent) -> Event { + let html = render_toast_html(event); + Event::default().event("toast").data(html) +} + +fn render_toast_html(event: &ToastEvent) -> String { + let id = event + .id + .clone() + .unwrap_or_else(|| format!("t-{}", chrono::Utc::now().timestamp_millis())); + let level = event.level.as_str(); + format!( + r##"
    {msg}
    "##, + id = encode_double_quoted_attribute(&id), + level = level, + level_attr = level, + msg = encode_text(&event.message), + ) +} + +/// SSE-stream-flavoured wrapper around plain stream construction. Used by +/// tests that want to inject events without spinning a real broadcast. +#[allow(dead_code)] +fn one_shot(event: ToastEvent) -> impl Stream> { + stream::once(async move { Ok::<_, Infallible>(toast_to_sse_event(&event)) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toast_html_carries_level_and_message() { + let html = render_toast_html(&ToastEvent { + level: ToastLevel::Success, + message: "Saved".into(), + id: Some("t1".into()), + }); + assert!(html.contains(r#"id="t1""#)); + assert!(html.contains("ds-toast-success")); + assert!(html.contains("Saved")); + } + + #[test] + fn toast_html_escapes_message() { + let html = render_toast_html(&ToastEvent { + level: ToastLevel::Error, + message: "".into(), + id: Some("t2".into()), + }); + assert!(html.contains("<bad>")); + assert!(!html.contains("")); + } + + #[test] + fn toast_html_generates_id_when_absent() { + let html = render_toast_html(&ToastEvent { + level: ToastLevel::Info, + message: "hello".into(), + id: None, + }); + // Auto-id starts with "t-". + assert!(html.contains(r#"id="t-"#)); + } + + #[test] + fn level_as_str_returns_kebab_for_css() { + assert_eq!(ToastLevel::Info.as_str(), "info"); + assert_eq!(ToastLevel::Warning.as_str(), "warning"); + } + + #[tokio::test] + async fn publish_returns_subscriber_count() { + // Can't assert an exact count because cargo test runs in parallel + // and other tests may hold receivers simultaneously. Just verify + // the call returns without panicking. + let _ = publish_toast(ToastEvent { + level: ToastLevel::Info, + message: "ignored".into(), + id: Some("t-zero".into()), + }); + } + + #[tokio::test] + async fn publish_reaches_active_subscriber() { + let mut rx = bus().subscribe(); + let _ = publish_toast(ToastEvent { + level: ToastLevel::Info, + message: "ping".into(), + id: Some("t-reach".into()), + }); + let received = rx.recv().await.expect("event received"); + assert_eq!(received.message, "ping"); + } + + #[test] + fn sse_event_uses_toast_event_name() { + // We can't easily inspect axum's Event internals; instead we render + // the event payload directly and confirm the html shape. + let ev = ToastEvent { + level: ToastLevel::Success, + message: "ok".into(), + id: Some("t-x".into()), + }; + let _ = toast_to_sse_event(&ev); // doesn't panic, contract holds + } +} diff --git a/crates/foundation/crates/rustelo_server/src/api/htmx_user.rs b/crates/foundation/crates/rustelo_server/src/api/htmx_user.rs new file mode 100644 index 0000000..3b005be --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/api/htmx_user.rs @@ -0,0 +1,482 @@ +//! HTMX user dashboard endpoints. +//! +//! Tabs are individual GET endpoints that return panel fragments. The shell +//! emits the tab strip + initial tab content; clicking a tab swaps only the +//! `#dashboard-content` region with `hx-push-url` so the back button works. +//! +//! Routes mounted under `/api/htmx/user`: +//! +//! - `GET /dashboard` — tab strip + active tab content. +//! - `GET /dashboard/account` — account info panel. +//! - `GET /dashboard/bookmarks` — bookmarks list panel. +//! - `POST /bookmarks` — add bookmark for the referrer URL. +//! - `DELETE /bookmarks/{id}` — remove bookmark. +//! - `GET /dashboard/notes` — notes list panel (read-only for now). + +#[cfg(feature = "auth")] +mod imp { + use std::sync::Arc; + + use axum::extract::{Path, Query, State}; + use axum::http::StatusCode; + use axum::response::{Html, IntoResponse, Response}; + use axum::routing::{delete, get, post}; + use axum::{Extension, Form, Router}; + use html_escape::{encode_double_quoted_attribute, encode_text}; + use serde::Deserialize; + + use crate::api::htmx_sse::{publish_toast, ToastEvent, ToastLevel}; + use crate::auth::middleware::AuthContext; + use crate::auth::AuthService; + use crate::database::auth::AddBookmarkRequest; + + #[derive(Debug, Deserialize)] + pub(super) struct DashboardQuery { + #[serde(default)] + pub(super) tab: Option, + } + + #[derive(Debug, Deserialize)] + pub(super) struct AddBookmarkForm { + pub(super) content_type: String, + pub(super) content_id: String, + #[serde(default)] + pub(super) content_title: Option, + #[serde(default)] + pub(super) content_url: Option, + } + + #[derive(Debug, Deserialize)] + pub(super) struct NoteForm { + pub(super) title: String, + pub(super) content: String, + } + + pub fn htmx_user_routes() -> Router> { + Router::new() + .route("/dashboard", get(dashboard)) + .route("/dashboard/account", get(tab_account)) + .route("/dashboard/bookmarks", get(tab_bookmarks)) + .route("/dashboard/notes", get(tab_notes)) + .route("/bookmarks", post(add_bookmark)) + .route("/bookmarks/{id}", delete(delete_bookmark)) + .route("/notes", post(add_note)) + .route("/notes/{id}", delete(delete_note).put(update_note)) + } + + async fn dashboard( + State(svc): State>, + Extension(auth): Extension, + Query(q): Query, + ) -> Response { + let Some(user) = auth.user.clone() else { + return unauthorised(); + }; + let active = q.tab.as_deref().unwrap_or("account"); + let panel = panel_for(&svc, &user, active).await; + let html = format!( + r#"
    {panel}
    "#, + tabs = render_tabs(active), + panel = panel, + ); + (StatusCode::OK, Html(html)).into_response() + } + + async fn tab_account(Extension(auth): Extension) -> Response { + let Some(user) = auth.user.as_ref() else { + return unauthorised(); + }; + let html = render_account(user); + (StatusCode::OK, Html(html)).into_response() + } + + async fn tab_bookmarks( + State(svc): State>, + Extension(auth): Extension, + ) -> Response { + let Some(user) = auth.user.as_ref() else { + return unauthorised(); + }; + let bookmarks = svc + .repository + .get_bookmarks(user.id) + .await + .unwrap_or_default(); + let html = render_bookmarks_list(&bookmarks); + (StatusCode::OK, Html(html)).into_response() + } + + async fn tab_notes( + State(svc): State>, + Extension(auth): Extension, + ) -> Response { + let Some(user) = auth.user.as_ref() else { + return unauthorised(); + }; + let notes = svc.repository.get_notes(user.id).await.unwrap_or_default(); + let html = render_notes_list(¬es); + (StatusCode::OK, Html(html)).into_response() + } + + async fn add_bookmark( + State(svc): State>, + Extension(auth): Extension, + Form(form): Form, + ) -> Response { + let Some(user) = auth.user.as_ref() else { + return unauthorised(); + }; + let req = AddBookmarkRequest { + user_id: user.id, + content_type: form.content_type, + content_id: form.content_id, + content_title: form.content_title, + content_url: form.content_url, + }; + if let Err(e) = svc.repository.add_bookmark(&req).await { + tracing::warn!("add_bookmark failed: {e}"); + publish_toast(ToastEvent { + level: ToastLevel::Error, + message: format!("Bookmark failed: {e}"), + id: None, + }); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(error_panel(&e.to_string())), + ) + .into_response(); + } + publish_toast(ToastEvent { + level: ToastLevel::Success, + message: "Bookmark saved".to_string(), + id: None, + }); + let bookmarks = svc + .repository + .get_bookmarks(user.id) + .await + .unwrap_or_default(); + (StatusCode::OK, Html(render_bookmarks_list(&bookmarks))).into_response() + } + + async fn delete_bookmark( + State(svc): State>, + Extension(auth): Extension, + Path(id): Path, + ) -> Response { + let Some(user) = auth.user.as_ref() else { + return unauthorised(); + }; + if let Err(e) = svc.repository.remove_bookmark(id, user.id).await { + tracing::warn!("remove_bookmark failed: {e}"); + publish_toast(ToastEvent { + level: ToastLevel::Error, + message: format!("Bookmark removal failed: {e}"), + id: None, + }); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(error_panel(&e.to_string())), + ) + .into_response(); + } + publish_toast(ToastEvent { + level: ToastLevel::Info, + message: "Bookmark removed".to_string(), + id: None, + }); + // Empty body + `hx-swap="delete"` on the row removes the element. + (StatusCode::OK, Html(String::new())).into_response() + } + + async fn add_note( + State(svc): State>, + Extension(auth): Extension, + Form(form): Form, + ) -> Response { + let Some(user) = auth.user.as_ref() else { + return unauthorised(); + }; + if form.title.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Html(error_panel("Title is required")), + ) + .into_response(); + } + if let Err(e) = svc + .repository + .create_note(user.id, form.title.trim(), form.content.trim()) + .await + { + tracing::warn!("create_note failed: {e}"); + publish_toast(ToastEvent { + level: ToastLevel::Error, + message: format!("Note save failed: {e}"), + id: None, + }); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(error_panel(&e.to_string())), + ) + .into_response(); + } + publish_toast(ToastEvent { + level: ToastLevel::Success, + message: "Note saved".into(), + id: None, + }); + let notes = svc.repository.get_notes(user.id).await.unwrap_or_default(); + (StatusCode::OK, Html(render_notes_list(¬es))).into_response() + } + + async fn update_note( + State(svc): State>, + Extension(auth): Extension, + Path(id): Path, + Form(form): Form, + ) -> Response { + let Some(user) = auth.user.as_ref() else { + return unauthorised(); + }; + if let Err(e) = svc + .repository + .update_note(id, user.id, form.title.trim(), form.content.trim()) + .await + { + tracing::warn!("update_note failed: {e}"); + publish_toast(ToastEvent { + level: ToastLevel::Error, + message: format!("Note update failed: {e}"), + id: None, + }); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(error_panel(&e.to_string())), + ) + .into_response(); + } + publish_toast(ToastEvent { + level: ToastLevel::Success, + message: "Note updated".into(), + id: None, + }); + let notes = svc.repository.get_notes(user.id).await.unwrap_or_default(); + (StatusCode::OK, Html(render_notes_list(¬es))).into_response() + } + + async fn delete_note( + State(svc): State>, + Extension(auth): Extension, + Path(id): Path, + ) -> Response { + let Some(user) = auth.user.as_ref() else { + return unauthorised(); + }; + if let Err(e) = svc.repository.delete_note(id, user.id).await { + tracing::warn!("delete_note failed: {e}"); + publish_toast(ToastEvent { + level: ToastLevel::Error, + message: format!("Note delete failed: {e}"), + id: None, + }); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Html(error_panel(&e.to_string())), + ) + .into_response(); + } + publish_toast(ToastEvent { + level: ToastLevel::Info, + message: "Note removed".into(), + id: None, + }); + (StatusCode::OK, Html(String::new())).into_response() + } + + async fn panel_for( + svc: &AuthService, + user: &rustelo_core_lib::auth::User, + active: &str, + ) -> String { + match active { + "bookmarks" => { + let bookmarks = svc + .repository + .get_bookmarks(user.id) + .await + .unwrap_or_default(); + render_bookmarks_list(&bookmarks) + } + "notes" => { + let notes = svc.repository.get_notes(user.id).await.unwrap_or_default(); + render_notes_list(¬es) + } + _ => render_account(user), + } + } + + fn render_tabs(active: &str) -> String { + let tabs = [ + ("account", "Account"), + ("bookmarks", "Bookmarks"), + ("notes", "Notes"), + ]; + let mut out = String::new(); + for (slug, label) in tabs { + let is_active = slug == active; + let aria = if is_active { + r#" aria-selected="true""# + } else { + "" + }; + let class = if is_active { " ds-tab-active" } else { "" }; + out.push_str(&format!( + r##""##, + )); + } + out + } + + fn render_account(user: &rustelo_core_lib::auth::User) -> String { + let username = encode_text(&user.username); + let email = encode_text(&user.email); + let display = user + .display_name + .as_deref() + .map(encode_text) + .unwrap_or_default(); + format!( + r#"
    Username
    {username}
    Email
    {email}
    Display name
    {display}
    "# + ) + } + + fn render_bookmarks_list(rows: &[crate::database::auth::BookmarkRow]) -> String { + if rows.is_empty() { + return r#"

    No bookmarks yet.

    "#.to_string(); + } + let mut out = String::from(r#"
      "#); + for row in rows { + let title = row + .content_title + .as_deref() + .map(encode_text) + .unwrap_or_else(|| encode_text(&row.content_id)); + let href = row + .content_url + .as_deref() + .map(encode_double_quoted_attribute) + .unwrap_or_default(); + let id = row.id; + out.push_str(&format!( + r##"
    • {title}
    • "##, + )); + } + out.push_str("
    "); + out + } + + fn render_notes_list(rows: &[crate::database::auth::NoteRow]) -> String { + let form = r##"
    "##; + if rows.is_empty() { + return format!( + r##"
    {form}

    No notes yet.

    "## + ); + } + let mut items = String::new(); + for row in rows { + let id = row.id; + let title = encode_text(&row.title); + let content = encode_text(&row.content); + items.push_str(&format!( + r##"
  • {title}

    {content}

  • "##, + )); + } + format!(r##"
    {form}
      {items}
    "##) + } + + fn error_panel(message: &str) -> String { + format!( + r#""#, + encode_text(message) + ) + } + + fn unauthorised() -> Response { + let html = r#""#; + (StatusCode::UNAUTHORIZED, Html(html)).into_response() + } + + #[cfg(test)] + mod tests { + use chrono::Utc; + use uuid::Uuid; + + use super::*; + + fn sample_user() -> rustelo_core_lib::auth::User { + rustelo_core_lib::auth::User { + id: Uuid::nil(), + email: "j@x".into(), + username: "j".into(), + display_name: Some("Jay".into()), + avatar_url: None, + roles: vec![], + is_active: true, + email_verified: true, + created_at: Utc::now(), + updated_at: Utc::now(), + last_login: None, + profile: rustelo_core_lib::auth::UserProfile::default(), + two_factor_enabled: false, + } + } + + #[test] + fn account_panel_renders_user_fields() { + let user = sample_user(); + let html = render_account(&user); + assert!(html.contains("j@x")); + assert!(html.contains(">j<")); + assert!(html.contains("Jay")); + } + + #[test] + fn tabs_mark_active_with_aria_selected() { + let html = render_tabs("bookmarks"); + assert!(html.contains(r#"aria-selected="true""#)); + // Three buttons; only one active. + assert_eq!(html.matches(r#"aria-selected="true""#).count(), 1); + assert_eq!(html.matches(" Router { + Router::new().route("/{form}/{field}", post(validate_field)) +} + +#[derive(Debug, Deserialize)] +struct AnyField { + #[serde(default)] + email: Option, + #[serde(default)] + password: Option, + #[serde(default)] + name: Option, + #[serde(default)] + message: Option, + #[serde(default)] + url: Option, + #[serde(default)] + code: Option, +} + +async fn validate_field( + Path((form, field)): Path<(String, String)>, + Form(values): Form, +) -> Response { + let value = match field.as_str() { + "email" => values.email.unwrap_or_default(), + "password" => values.password.unwrap_or_default(), + "name" => values.name.unwrap_or_default(), + "message" => values.message.unwrap_or_default(), + "url" => values.url.unwrap_or_default(), + "code" => values.code.unwrap_or_default(), + _ => String::new(), + }; + + let validator = pick_validator(form.as_str(), field.as_str()); + let outcome = validator(&value); + let html = render_field(&form, &field, &value, outcome); + (StatusCode::OK, Html(html)).into_response() +} + +#[derive(Debug, Clone)] +pub(crate) enum Outcome { + Ok, + Error(&'static str), +} + +type Validator = fn(&str) -> Outcome; + +fn pick_validator(form: &str, field: &str) -> Validator { + match (form, field) { + (_, "email") => validate_email, + (_, "password") => validate_password, + ("contact", "name") => validate_required_short, + ("contact", "message") => validate_message, + ("work-request", "name") => validate_required_short, + ("work-request", "message") => validate_message, + (_, "url") => validate_url_optional, + (_, "code") => validate_otp_code, + _ => validate_always_ok, + } +} + +fn validate_email(v: &str) -> Outcome { + if v.is_empty() { + return Outcome::Error("Email is required"); + } + // Minimal RFC-light check: presence of @ with at least one char each side + // and a dot in the domain part. + if let Some(at) = v.find('@') { + let (local, domain) = v.split_at(at); + let domain = &domain[1..]; + if !local.is_empty() + && domain.contains('.') + && !domain.starts_with('.') + && !domain.ends_with('.') + { + return Outcome::Ok; + } + } + Outcome::Error("Enter a valid email address") +} + +fn validate_password(v: &str) -> Outcome { + if v.len() < 8 { + return Outcome::Error("Password must be at least 8 characters"); + } + if !v.chars().any(|c| c.is_ascii_digit()) { + return Outcome::Error("Password must contain at least one digit"); + } + if !v.chars().any(|c| c.is_ascii_alphabetic()) { + return Outcome::Error("Password must contain at least one letter"); + } + Outcome::Ok +} + +fn validate_required_short(v: &str) -> Outcome { + let trimmed = v.trim(); + if trimmed.is_empty() { + Outcome::Error("This field is required") + } else if trimmed.chars().count() > 100 { + Outcome::Error("Keep it under 100 characters") + } else { + Outcome::Ok + } +} + +fn validate_message(v: &str) -> Outcome { + let trimmed = v.trim(); + let count = trimmed.chars().count(); + if count < 10 { + Outcome::Error("Message must be at least 10 characters") + } else if count > 5000 { + Outcome::Error("Message exceeds 5000 characters") + } else { + Outcome::Ok + } +} + +fn validate_url_optional(v: &str) -> Outcome { + if v.trim().is_empty() { + return Outcome::Ok; + } + if v.starts_with("http://") || v.starts_with("https://") { + Outcome::Ok + } else { + Outcome::Error("URL must start with http:// or https://") + } +} + +fn validate_otp_code(v: &str) -> Outcome { + if v.len() == 6 && v.chars().all(|c| c.is_ascii_digit()) { + Outcome::Ok + } else { + Outcome::Error("Enter the 6-digit code") + } +} + +fn validate_always_ok(_: &str) -> Outcome { + Outcome::Ok +} + +fn render_field(form: &str, field: &str, value: &str, outcome: Outcome) -> String { + let id = format!("field-{form}-{field}"); + let endpoint = format!("/api/htmx/validate/{form}/{field}"); + let error = match outcome { + Outcome::Ok => String::new(), + Outcome::Error(msg) => format!( + r#"{}"#, + encode_text(msg) + ), + }; + let kind = input_type_for(field); + let value_attr = encode_double_quoted_attribute(value); + // Re-emit the whole `.ds-field` so the client can swap with hx-swap=outerHTML + // on the field wrapper. + format!( + r##"
    {error}
    "##, + id = encode_double_quoted_attribute(&id), + label = encode_text(&humanise_label(field)), + kind = kind, + field = encode_double_quoted_attribute(field), + endpoint = encode_double_quoted_attribute(&endpoint), + ) +} + +fn input_type_for(field: &str) -> &'static str { + match field { + "email" => "email", + "password" => "password", + "url" => "url", + "code" => "text", + _ => "text", + } +} + +fn humanise_label(field: &str) -> String { + let mut chars = field.chars(); + let first = chars + .next() + .map(|c| c.to_ascii_uppercase().to_string()) + .unwrap_or_default(); + format!( + "{first}{}", + chars.as_str().replace('-', " ").replace('_', " ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_validator_accepts_well_formed() { + assert!(matches!(validate_email("a@b.co"), Outcome::Ok)); + assert!(matches!( + validate_email("first.last+tag@sub.example.com"), + Outcome::Ok + )); + } + + #[test] + fn email_validator_rejects_garbage() { + assert!(matches!(validate_email(""), Outcome::Error(_))); + assert!(matches!(validate_email("plain"), Outcome::Error(_))); + assert!(matches!(validate_email("no@dot"), Outcome::Error(_))); + assert!(matches!(validate_email("@example.com"), Outcome::Error(_))); + assert!(matches!(validate_email("a@.com"), Outcome::Error(_))); + } + + #[test] + fn password_validator_enforces_complexity() { + assert!(matches!(validate_password("short1a"), Outcome::Error(_))); + assert!(matches!(validate_password("12345678"), Outcome::Error(_))); + assert!(matches!(validate_password("abcdefgh"), Outcome::Error(_))); + assert!(matches!(validate_password("password1"), Outcome::Ok)); + } + + #[test] + fn otp_code_validator_requires_six_digits() { + assert!(matches!(validate_otp_code("123456"), Outcome::Ok)); + assert!(matches!(validate_otp_code("12345"), Outcome::Error(_))); + assert!(matches!(validate_otp_code("12345a"), Outcome::Error(_))); + } + + #[test] + fn message_validator_bounds() { + assert!(matches!(validate_message("short"), Outcome::Error(_))); + assert!(matches!(validate_message(&"x".repeat(20)), Outcome::Ok)); + assert!(matches!( + validate_message(&"x".repeat(5001)), + Outcome::Error(_) + )); + } + + #[test] + fn render_field_includes_error_when_invalid() { + let html = render_field("contact", "email", "bad", Outcome::Error("Bad")); + assert!(html.contains("Bad")); + assert!(html.contains(r#"role="alert""#)); + } + + #[test] + fn render_field_omits_error_when_ok() { + let html = render_field("contact", "email", "ok@x.co", Outcome::Ok); + assert!(!html.contains(r#"role="alert""#)); + } + + #[test] + fn render_field_carries_endpoint_and_blur_trigger() { + let html = render_field("contact", "email", "", Outcome::Ok); + assert!(html.contains(r#"hx-post="/api/htmx/validate/contact/email""#)); + assert!(html.contains(r#"hx-trigger="blur""#)); + } +} diff --git a/crates/foundation/crates/rustelo_server/src/api/i18n.rs b/crates/foundation/crates/rustelo_server/src/api/i18n.rs new file mode 100644 index 0000000..213e11b --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/api/i18n.rs @@ -0,0 +1,42 @@ +//! i18n API routes — progressive translation loading for WASM clients + +use std::collections::HashMap; + +use axum::{extract::Path, http::StatusCode, response::Json, routing::get, Router}; + +/// Return all parsed translations for `language` as a flat key→value map. +/// +/// Reads from `FTL_REGISTRY`, which is populated at server startup by the +/// implementation's `ResourceContributor::contribute_ftl()`. Returns 404 when +/// no FTL data is registered for the requested language. +async fn get_translations( + Path(language): Path, +) -> Result>, StatusCode> { + tracing::debug!( + "i18n API: fetching translations for language '{}'", + language + ); + + match rustelo_core_lib::registration::get_parsed_ftl_for_language(&language) { + Some(translations) if !translations.is_empty() => { + tracing::debug!( + "i18n API: returning {} keys for language '{}'", + translations.len(), + language + ); + Ok(Json(translations)) + } + _ => { + tracing::warn!( + "i18n API: no translations found for language '{}'", + language + ); + Err(StatusCode::NOT_FOUND) + } + } +} + +/// Create i18n API routes +pub fn create_i18n_routes() -> Router { + Router::new().route("/i18n/{language}", get(get_translations)) +} diff --git a/crates/foundation/crates/rustelo_server/src/api/mod.rs b/crates/foundation/crates/rustelo_server/src/api/mod.rs index 97eb9f8..439aec4 100644 --- a/crates/foundation/crates/rustelo_server/src/api/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/api/mod.rs @@ -3,8 +3,50 @@ //! This module contains API endpoints that are feature-gated and can be //! enabled/disabled based on the server configuration. +#[cfg(feature = "leptos-hydration")] +pub mod activity_download; +pub mod filter; +pub mod htmx; +pub mod htmx_auth; +#[cfg(feature = "email")] +pub mod htmx_contact; +pub mod htmx_content; +pub mod htmx_cookies; +pub mod htmx_form; +pub mod htmx_newsletter; +#[cfg(feature = "post-engagement")] +pub mod htmx_post; +pub mod htmx_sse; +pub mod htmx_user; +pub mod htmx_validate; +pub mod i18n; pub mod menu; pub mod nav_test; +#[cfg(feature = "leptos-hydration")] +pub use activity_download::{activity_routes, generate_callback_url}; +pub use filter::{ + filter_routes, register_filter_renderer, FilterError, FilterQuery, FilterRenderer, +}; +pub use htmx::htmx_routes; +#[cfg(feature = "auth")] +pub use htmx_auth::htmx_auth_routes; +#[cfg(feature = "email")] +pub use htmx_contact::htmx_contact_routes; +pub use htmx_content::{ + htmx_content_routes, register_content_grid_renderer, ContentGridError, ContentGridQuery, + ContentGridRenderer, +}; +pub use htmx_cookies::{has_consent as has_cookie_consent, htmx_cookies_routes}; +pub use htmx_form::htmx_form_routes; +#[cfg(feature = "email")] +pub use htmx_newsletter::htmx_newsletter_routes; +#[cfg(feature = "post-engagement")] +pub use htmx_post::htmx_post_routes; +pub use htmx_sse::{htmx_sse_routes, publish_toast, ToastEvent, ToastLevel}; +#[cfg(feature = "auth")] +pub use htmx_user::htmx_user_routes; +pub use htmx_validate::htmx_validate_routes; +pub use i18n::create_i18n_routes; pub use menu::create_menu_routes; pub use nav_test::router as nav_test_router; diff --git a/crates/foundation/crates/rustelo_server/src/app_state.rs b/crates/foundation/crates/rustelo_server/src/app_state.rs index 584eb6e..b1afe4e 100644 --- a/crates/foundation/crates/rustelo_server/src/app_state.rs +++ b/crates/foundation/crates/rustelo_server/src/app_state.rs @@ -4,26 +4,19 @@ //! This module contains the AppState struct and related functionality for //! managing shared application services and configuration. -#[cfg(any( - feature = "auth", - feature = "content-db", - feature = "email", - feature = "metrics" -))] use std::sync::Arc; #[cfg(feature = "auth")] -use auth::{AuthService, JwtService}; +use crate::auth::{AuthService, JwtService}; #[cfg(feature = "content-db")] -use content::ContentService; +use crate::content::ContentService; #[cfg(any(feature = "auth", feature = "content-db"))] -use database; +use crate::database; #[cfg(feature = "email")] -use email::EmailService; -use leptos::prelude::*; +use crate::email::EmailService; #[cfg(feature = "metrics")] -use metrics; - +use crate::metrics; +use crate::run::SiteOptions; use crate::security::{csrf::CsrfState, rate_limit::RateLimiter}; /// Unified application state that works with Axum's single-state requirement @@ -34,7 +27,7 @@ use crate::security::{csrf::CsrfState, rate_limit::RateLimiter}; #[derive(Clone)] pub struct AppState { /// Leptos configuration options for SSR - pub leptos_options: LeptosOptions, + pub site_options: SiteOptions, /// CSRF protection state pub csrf_state: CsrfState, @@ -54,6 +47,10 @@ pub struct AppState { #[cfg(feature = "auth")] pub auth_repository: Arc, + /// Per-post engagement repository (views/ratings/messages, feature-gated) + #[cfg(feature = "post-engagement")] + pub engagement_repository: Arc, + /// Content management service (optional, feature-gated) #[cfg(feature = "content-db")] pub content_service: Arc, @@ -65,6 +62,18 @@ pub struct AppState { /// Metrics registry (optional, feature-gated) #[cfg(feature = "metrics")] pub metrics_registry: Option>, + + /// Shared, hot-reloadable file RBAC config (optional, feature-gated) + #[cfg(feature = "rbac")] + pub file_rbac_config: Arc>, + + /// WebSocket server for real-time notifications (RBAC sync, content + /// updates) + pub ws_server: Arc, + + /// NATS admin client for the operational bus (optional, feature-gated) + #[cfg(feature = "nats-admin")] + pub nats_client: Option>, } impl AppState { @@ -72,19 +81,28 @@ impl AppState { /// /// This is typically called from the server startup process with all /// the required services properly initialized. + #[allow(clippy::too_many_arguments)] // aggregating constructor for all shared services pub fn new( - leptos_options: LeptosOptions, + site_options: SiteOptions, csrf_state: CsrfState, rate_limiter: RateLimiter, #[cfg(feature = "auth")] auth_service: Arc, #[cfg(feature = "auth")] jwt_service: Arc, #[cfg(feature = "auth")] auth_repository: Arc, + #[cfg(feature = "post-engagement")] engagement_repository: Arc< + crate::database::engagement::EngagementRepository, + >, #[cfg(feature = "content-db")] content_service: Arc, #[cfg(feature = "email")] email_service: Arc, #[cfg(feature = "metrics")] metrics_registry: Option>, + #[cfg(feature = "rbac")] file_rbac_config: Arc< + tokio::sync::RwLock, + >, + ws_server: Arc, + #[cfg(feature = "nats-admin")] nats_client: Option>, ) -> Self { Self { - leptos_options, + site_options, csrf_state, rate_limiter, #[cfg(feature = "auth")] @@ -93,12 +111,19 @@ impl AppState { jwt_service, #[cfg(feature = "auth")] auth_repository, + #[cfg(feature = "post-engagement")] + engagement_repository, #[cfg(feature = "content-db")] content_service, #[cfg(feature = "email")] email_service, #[cfg(feature = "metrics")] metrics_registry, + #[cfg(feature = "rbac")] + file_rbac_config, + ws_server, + #[cfg(feature = "nats-admin")] + nats_client, } } } diff --git a/crates/foundation/crates/rustelo_server/src/auth/jwt.rs b/crates/foundation/crates/rustelo_server/src/auth/jwt.rs index 156ba85..13d54c8 100644 --- a/crates/foundation/crates/rustelo_server/src/auth/jwt.rs +++ b/crates/foundation/crates/rustelo_server/src/auth/jwt.rs @@ -24,6 +24,15 @@ pub struct TokenPair { pub token_type: String, } +#[derive(Debug, Serialize, Deserialize)] +pub struct PendingGdprClaims { + pub sub: String, + pub token_type: String, + pub exp: usize, + pub iat: usize, + pub iss: String, +} + #[derive(Debug, Serialize, Deserialize)] pub struct RefreshTokenClaims { pub sub: String, @@ -177,6 +186,41 @@ impl JwtService { Ok(claims.roles) } + pub fn generate_pending_gdpr_token( + &self, + email: &str, + ) -> Result> { + let now = Utc::now(); + let expires_at = now + Duration::hours(1); + + let claims = PendingGdprClaims { + sub: email.to_string(), + token_type: "pending_gdpr".to_string(), + exp: expires_at.timestamp() as usize, + iat: now.timestamp() as usize, + iss: self.issuer.clone(), + }; + + let header = Header::new(self.algorithm); + Ok(encode(&header, &claims, &self.encoding_key)?) + } + + pub fn verify_pending_gdpr_token( + &self, + token: &str, + ) -> Result> { + let mut validation = Validation::new(self.algorithm); + validation.set_issuer(&[&self.issuer]); + + let token_data = decode::(token, &self.decoding_key, &validation)?; + + if token_data.claims.token_type != "pending_gdpr" { + return Err("Invalid token type".into()); + } + + Ok(token_data.claims) + } + pub fn refresh_access_token( &self, refresh_token: &str, diff --git a/crates/foundation/crates/rustelo_server/src/auth/middleware.rs b/crates/foundation/crates/rustelo_server/src/auth/middleware.rs index e64eb43..4c7e975 100644 --- a/crates/foundation/crates/rustelo_server/src/auth/middleware.rs +++ b/crates/foundation/crates/rustelo_server/src/auth/middleware.rs @@ -98,20 +98,40 @@ async fn extract_auth_context( cookies: &Cookies, headers: &HeaderMap, ) -> AuthContext { - // Try to get user from JWT token first + // 1. JWT in Authorization: Bearer header (API / programmatic clients) if let Some(user) = extract_user_from_jwt(jwt_service, headers).await { return AuthContext::with_user(user); } - // Try to get user from session cookie + // 2. JWT stored in the session_token cookie (OTP / passwordless login path). + // OTP auth issues a signed JWT and stores it in this HttpOnly cookie; it + // does NOT create a DB session row, so we verify the signature directly. + if let Some(user) = extract_user_from_cookie_jwt(jwt_service, cookies).await { + return AuthContext::with_user(user); + } + + // 3. Traditional session cookie → DB session lookup if let Some((user, session_id)) = extract_user_from_session(auth_repo, cookies).await { return AuthContext::with_session(user, session_id); } - // No authentication found AuthContext::new() } +/// Extract and verify a JWT stored in the `session_token` cookie. +async fn extract_user_from_cookie_jwt(jwt_service: &JwtService, cookies: &Cookies) -> Option { + let token = cookies.get("session_token")?.value().to_string(); + extract_user_from_jwt(jwt_service, &{ + // Re-use extract_user_from_jwt by synthesising an Authorization header value. + let mut map = HeaderMap::new(); + if let Ok(val) = axum::http::HeaderValue::from_str(&format!("Bearer {}", token)) { + map.insert(header::AUTHORIZATION, val); + } + map + }) + .await +} + /// Extract user from JWT token in Authorization header async fn extract_user_from_jwt(jwt_service: &JwtService, headers: &HeaderMap) -> Option { let auth_header = headers.get(header::AUTHORIZATION)?.to_str().ok()?; @@ -145,7 +165,7 @@ async fn extract_user_from_session( auth_repo: &AuthRepository, cookies: &Cookies, ) -> Option<(User, String)> { - let session_cookie = cookies.get("session_id")?; + let session_cookie = cookies.get("session_token")?; let session_id = session_cookie.value(); let session = auth_repo.find_session(session_id).await.ok()??; diff --git a/crates/foundation/crates/rustelo_server/src/auth/mod.rs b/crates/foundation/crates/rustelo_server/src/auth/mod.rs index cbc97e5..801d6d1 100644 --- a/crates/foundation/crates/rustelo_server/src/auth/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/auth/mod.rs @@ -1,6 +1,9 @@ pub mod jwt; pub mod middleware; pub mod oauth; +pub mod otp; +pub mod otp_adapter; +pub mod otp_store; pub mod password; pub mod repository; @@ -11,14 +14,15 @@ pub mod two_factor; pub use jwt::JwtService; pub use middleware::auth_middleware; pub use oauth::OAuthService; +pub use otp::OtpService; +#[cfg(all(feature = "auth", feature = "leptos-hydration"))] +pub use otp_adapter::OtpServiceAdapter; +pub use otp_store::{MemoryOtpStore, OtpStore}; pub use password::PasswordService; pub use routes::create_auth_routes; pub use service::AuthService; pub use two_factor::TwoFactorService; -// RBAC functionality moved to crate::rbac module -#[cfg(feature = "rbac")] -pub use crate::rbac::{ - rbac_middleware, require_database_access, require_file_access, ConditionalRBACService, - NoOpRBACService, RBACConfigLoader, RBACRepository, RBACService, -}; +// Database-driven RBAC types (from crate::rbac, requires auth feature) +#[cfg(feature = "auth")] +pub use crate::rbac::{ConditionalRBACService, RBACConfigLoader}; diff --git a/crates/foundation/crates/rustelo_server/src/auth/oauth.rs b/crates/foundation/crates/rustelo_server/src/auth/oauth.rs index a0c17c7..cad7666 100644 --- a/crates/foundation/crates/rustelo_server/src/auth/oauth.rs +++ b/crates/foundation/crates/rustelo_server/src/auth/oauth.rs @@ -230,6 +230,51 @@ impl OAuthService { .build() .map_err(|e| AuthError::OAuthError(format!("Failed to create HTTP client: {}", e)))?; + // Concrete Sized error for the adapter — Box is not Sized so cannot + // satisfy the AsyncHttpClient bound `RE: Error + 'static`. + #[derive(Debug)] + struct HttpAdapterError(String); + impl std::fmt::Display for HttpAdapterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } + } + impl std::error::Error for HttpAdapterError {} + + // Adapter closure: bridges oauth2 5.0 HttpRequest → reqwest 0.13 → oauth2 + // HttpResponse. Required because oauth2/reqwest feature targets reqwest + // 0.12, not 0.13. + let adapter = { + let client = http_client.clone(); + move |request: oauth2::HttpRequest| { + let c = client.clone(); + async move { + let (parts, body) = request.into_parts(); + let resp = c + .request(parts.method, parts.uri.to_string()) + .headers(parts.headers) + .body(body) + .send() + .await + .map_err(|e| HttpAdapterError(e.to_string()))?; + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp + .bytes() + .await + .map_err(|e| HttpAdapterError(e.to_string()))? + .to_vec(); + let mut builder = axum::http::Response::builder().status(status); + for (name, value) in &headers { + builder = builder.header(name, value); + } + builder + .body(bytes) + .map_err(|e| HttpAdapterError(e.to_string())) + } + } + }; + // Exchange authorization code for access token let mut token_request = client.exchange_code(AuthorizationCode::new(callback.code)); @@ -239,7 +284,7 @@ impl OAuthService { } let token_response = token_request - .request_async(&http_client) + .request_async(&adapter) .await .map_err(|e| AuthError::OAuthError(format!("Token exchange failed: {}", e)))?; diff --git a/crates/foundation/crates/rustelo_server/src/auth/otp.rs b/crates/foundation/crates/rustelo_server/src/auth/otp.rs new file mode 100644 index 0000000..1dc4e7c --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/auth/otp.rs @@ -0,0 +1,259 @@ +//! OTP (passwordless) authentication service. + +#[cfg(feature = "email")] +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rustelo_core_lib::auth::{AuthError, AuthResponse, User}; +#[cfg(feature = "email")] +use serde_json::Value; + +use super::{ + jwt::JwtService, + otp_store::OtpStore, + password::PasswordService, + repository::{AuthRepository, AuthRepositoryTrait}, +}; +#[cfg(feature = "email")] +use crate::email::EmailService; + +/// Outcome of `verify_otp` — distinguishes returning users from new ones +/// that have not yet accepted GDPR consent. +pub enum OtpVerifyOutcome { + /// Email belongs to an existing account — session is ready. + Authenticated(AuthResponse), + /// Email is new — caller must obtain GDPR consent before creating the + /// account. + NeedsConsent { pending_token: String }, +} + +pub struct OtpService { + pub store: Arc, + pub password_svc: Arc, + #[cfg(feature = "email")] + pub email_svc: Arc, + pub auth_repo: Arc, + pub jwt_svc: Arc, +} + +impl OtpService { + /// Generate and send a one-time code to `email`. + /// + /// Rate-limits to one code per minute: if a valid entry was issued less + /// than `otp_expiry_minutes - 1` minutes ago it is still "fresh" and we + /// reject. + pub async fn request_otp( + &self, + email: &str, + code_len: u8, + expiry_minutes: u64, + lang: &str, + ) -> Result<(), AuthError> { + // Rate-limit: reject if a fresh (< 1 min old) valid entry already exists. + if let Some(existing) = self.store.get(email).await? { + let fresh_threshold = + Instant::now() + Duration::from_secs((expiry_minutes.saturating_sub(1)) * 60); + if existing.expires_at > fresh_threshold { + return Err(AuthError::RateLimitExceeded); + } + } + + // Generate a zero-padded N-digit code. + let max = 10u32.pow(u32::from(code_len)); + let code_num: u32 = rand::random::() % max; + let code = format!("{:0>width$}", code_num, width = code_len as usize); + + // Hash via Argon2 (sync). + let code_hash = self + .password_svc + .hash_password(&code) + .map_err(|_| AuthError::InternalError)?; + + let expires_at = Instant::now() + Duration::from_secs(expiry_minutes * 60); + self.store.insert(email, &code_hash, expires_at).await?; + + // Send email. + #[cfg(feature = "email")] + { + use rustelo_core_lib::t_for_language; + let subject = t_for_language(lang, "auth-email-otp-subject"); + let expire_msg = t_for_language(lang, "auth-email-otp-expire-msg") + .replace("%minutes%", &expiry_minutes.to_string()); + + let mut data: HashMap = HashMap::new(); + data.insert("code".to_string(), Value::String(code)); + data.insert( + "title".to_string(), + Value::String(t_for_language(lang, "auth-email-otp-title")), + ); + data.insert( + "subtitle".to_string(), + Value::String(t_for_language(lang, "auth-email-otp-subtitle")), + ); + data.insert("expire_msg".to_string(), Value::String(expire_msg)); + data.insert( + "ignore_msg".to_string(), + Value::String(t_for_language(lang, "auth-email-otp-ignore-msg")), + ); + + self.email_svc + .send_templated_email_with_language(email, &subject, "otp_code", data, lang) + .await + .map_err(|e| { + tracing::error!("OTP email send failed for {}: {}", email, e); + AuthError::InternalError + })?; + } + + #[cfg(not(feature = "email"))] + { + let _ = lang; // only used when email feature is enabled + tracing::warn!("OTP requested for {} but email feature is disabled", email); + } + + Ok(()) + } + + /// Verify the submitted code. + /// + /// Returns `OtpVerifyOutcome::Authenticated` if the email belongs to an + /// existing user, or `OtpVerifyOutcome::NeedsConsent` if the email is new + /// and requires GDPR acceptance before account creation. + pub async fn verify_otp( + &self, + email: &str, + code: &str, + max_attempts: u8, + ) -> Result { + let entry = self + .store + .get(email) + .await? + .ok_or(AuthError::InvalidToken)?; + + if entry.expires_at <= Instant::now() { + return Err(AuthError::TokenExpired); + } + + let attempts = self.store.increment_attempts(email).await?; + if attempts > max_attempts { + self.store.mark_used(email).await?; + return Err(AuthError::TooMany2FAAttempts); + } + + let valid = self + .password_svc + .verify_password(code, &entry.code_hash) + .map_err(|_| AuthError::InternalError)?; + + if !valid { + return Err(AuthError::InvalidCredentials); + } + + self.store.mark_used(email).await?; + + // Check whether the account already exists. + let existing = self + .auth_repo + .find_user_by_email(email) + .await + .map_err(|_| AuthError::DatabaseError)?; + + if let Some(db_user) = existing { + // Existing user: authenticate immediately. + let user: User = db_user.into(); + let token_pair = self + .jwt_svc + .generate_token_pair(&user) + .map_err(|_| AuthError::InternalError)?; + + Ok(OtpVerifyOutcome::Authenticated(AuthResponse { + user, + access_token: token_pair.access_token, + refresh_token: Some(token_pair.refresh_token), + expires_in: token_pair.expires_in, + token_type: token_pair.token_type, + requires_2fa: false, + })) + } else { + // New email: issue a short-lived pending token for the GDPR step. + let pending_token = self + .jwt_svc + .generate_pending_gdpr_token(email) + .map_err(|_| AuthError::InternalError)?; + + Ok(OtpVerifyOutcome::NeedsConsent { pending_token }) + } + } + + /// Accept GDPR consent for a pending registration. + /// + /// Validates the `pending_token` issued by `verify_otp`, creates the user + /// account, records the consent timestamp, and returns auth tokens. + pub async fn accept_gdpr(&self, pending_token: &str) -> Result { + let claims = self + .jwt_svc + .verify_pending_gdpr_token(pending_token) + .map_err(|_| AuthError::InvalidToken)?; + + let email = &claims.sub; + + // Guard against replay if the user already exists. + if self + .auth_repo + .find_user_by_email(email) + .await + .map_err(|_| AuthError::DatabaseError)? + .is_some() + { + return Err(AuthError::ValidationError( + "Account already exists for this email".to_string(), + )); + } + + use crate::database::auth::CreateUserRequest; + let req = CreateUserRequest { + email: email.to_string(), + username: None, + password_hash: String::new(), + display_name: None, + is_verified: true, + is_active: true, + }; + + let db_user = self + .auth_repo + .create_user(&req) + .await + .map_err(|_| AuthError::DatabaseError)?; + + // Record GDPR consent timestamp (best-effort — does not abort on error). + if let Err(e) = self.auth_repo.update_gdpr_accepted_at(db_user.id).await { + tracing::warn!("Could not store gdpr_accepted_at for {}: {}", email, e); + } + + let user: User = db_user.into(); + let token_pair = self + .jwt_svc + .generate_token_pair(&user) + .map_err(|_| AuthError::InternalError)?; + + Ok(AuthResponse { + user, + access_token: token_pair.access_token, + refresh_token: Some(token_pair.refresh_token), + expires_in: token_pair.expires_in, + token_type: token_pair.token_type, + requires_2fa: false, + }) + } + + /// Invalidate the session identified by `session_token`. + pub async fn logout_session(&self, session_token: &str) -> Result<(), AuthError> { + self.auth_repo + .delete_session(session_token) + .await + .map_err(|_| AuthError::DatabaseError) + } +} diff --git a/crates/foundation/crates/rustelo_server/src/auth/otp_adapter.rs b/crates/foundation/crates/rustelo_server/src/auth/otp_adapter.rs new file mode 100644 index 0000000..bbbef9c --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/auth/otp_adapter.rs @@ -0,0 +1,810 @@ +/// Adapter that bridges `rustelo_server::auth::OtpService` to the +/// `rustelo_components_leptos::auth::OtpServiceFacade` trait so that Leptos +/// server functions defined in `rustelo_components_leptos` can invoke the +/// concrete OTP backend without a reverse dependency. +#[cfg(all(feature = "auth", feature = "leptos-hydration"))] +pub mod adapter { + use std::sync::Arc; + + use rustelo_components_leptos::auth::{ + otp_service::{ + server::{BoxFuture, OtpServiceFacade}, + OtpVerifyResult, + }, + server_fns::{ + BookmarkInfo, CreatedServiceToken, ServiceTokenInfo, UserMessage, UserNote, + UserResource, + }, + }; + use rustelo_core_lib::auth::User; + use rustelo_core_lib::state::UserInfo; + use uuid::Uuid; + + use crate::{ + auth::OtpService, config::AuthOtpConfig, content::websocket::WebSocketServer, + database::auth::AuthRepositoryTrait, + }; + + pub struct OtpServiceAdapter { + pub otp_service: Arc, + pub config: AuthOtpConfig, + /// Optional WS server reference for security event notifications. + /// Set to `Some` in startup to enable cross-device security alerts and + /// new-device-login notifications. + pub ws_server: Option>, + } + + impl OtpServiceAdapter { + /// Resolve a session token to a user UUID via JWT validation. + fn resolve_user_id(svc: &OtpService, session_token: &str) -> Result { + let claims = svc + .jwt_svc + .verify_access_token(session_token) + .map_err(|e| format!("Invalid session: {e}"))?; + + Uuid::parse_str(&claims.sub).map_err(|e| format!("Invalid user ID in token: {e}")) + } + } + + impl OtpServiceFacade for OtpServiceAdapter { + fn request_otp(&self, email: &str, lang: &str) -> BoxFuture> { + let svc = self.otp_service.clone(); + let email = email.to_string(); + let lang = lang.to_string(); + let code_len = self.config.otp_code_length; + let expiry = self.config.otp_expiry_minutes; + Box::pin(async move { + svc.request_otp(&email, code_len, expiry, &lang) + .await + .map_err(|e| e.to_string()) + }) + } + + fn verify_otp( + &self, + email: &str, + code: &str, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let ws = self.ws_server.clone(); + let email = email.to_string(); + let code = code.to_string(); + let max = self.config.otp_max_attempts; + Box::pin(async move { + use rustelo_core_lib::auth::AuthError; + + use crate::auth::otp::OtpVerifyOutcome; + use crate::content::websocket::ContentNotification; + + let outcome_result = svc.verify_otp(&email, &code, max).await; + + // Security alert: notify existing sessions on brute-force detection. + if let Err(AuthError::TooMany2FAAttempts) = &outcome_result { + if let Some(ref ws_ref) = ws { + if let Ok(Some(db_user)) = svc.auth_repo.find_user_by_email(&email).await { + ws_ref + .notify_user( + db_user.id, + ContentNotification::SecurityAlert { + message: "Too many failed OTP attempts".to_string(), + }, + ) + .await; + } + } + } + + let outcome = outcome_result.map_err(|e| e.to_string())?; + + Ok(match outcome { + OtpVerifyOutcome::Authenticated(resp) => { + // New-device-login: notify other active sessions of the same user. + if let Some(ref ws_ref) = ws { + let session_hint = if resp.access_token.len() >= 8 { + format!("session:{}", &resp.access_token[..8]) + } else { + "session:new".to_string() + }; + ws_ref + .notify_user( + resp.user.id, + ContentNotification::NewDeviceLogin { + session_id: session_hint, + }, + ) + .await; + } + + let user = resp.user; + OtpVerifyResult::Authenticated { + user: UserInfo { + id: user.id.to_string(), + email: user.email, + name: user.display_name, + roles: user.roles.iter().map(|r| format!("{r:?}")).collect(), + avatar_url: user.avatar_url, + }, + session_token: resp.access_token, + } + } + OtpVerifyOutcome::NeedsConsent { pending_token } => { + OtpVerifyResult::NeedsConsent { pending_token } + } + }) + }) + } + + fn accept_gdpr(&self, pending_token: &str) -> BoxFuture> { + let svc = self.otp_service.clone(); + let token = pending_token.to_string(); + Box::pin(async move { + let resp = svc.accept_gdpr(&token).await.map_err(|e| e.to_string())?; + + let user = resp.user; + Ok(OtpVerifyResult::Authenticated { + user: UserInfo { + id: user.id.to_string(), + email: user.email, + name: user.display_name, + roles: user.roles.iter().map(|r| format!("{r:?}")).collect(), + avatar_url: user.avatar_url, + }, + session_token: resp.access_token, + }) + }) + } + + fn logout_session(&self, session_token: &str) -> BoxFuture> { + let svc = self.otp_service.clone(); + let token = session_token.to_string(); + Box::pin(async move { svc.logout_session(&token).await.map_err(|e| e.to_string()) }) + } + + fn get_bookmarks( + &self, + session_token: &str, + ) -> BoxFuture, String>> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let rows = svc + .auth_repo + .get_bookmarks(user_id) + .await + .map_err(|e| e.to_string())?; + + Ok(rows + .into_iter() + .map(|r| BookmarkInfo { + id: r.id.to_string(), + content_type: r.content_type, + content_id: r.content_id, + content_title: r.content_title, + content_url: r.content_url, + }) + .collect()) + }) + } + + fn add_bookmark( + &self, + session_token: &str, + content_type: String, + content_id: String, + content_title: Option, + content_url: Option, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + use crate::database::auth::AddBookmarkRequest; + let req = AddBookmarkRequest { + user_id, + content_type, + content_id, + content_title, + content_url, + }; + let id = svc + .auth_repo + .add_bookmark(&req) + .await + .map_err(|e| e.to_string())?; + + Ok(id.to_string()) + }) + } + + fn remove_bookmark( + &self, + session_token: &str, + bookmark_id: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let bm_id = Uuid::parse_str(&bookmark_id) + .map_err(|e| format!("Invalid bookmark ID: {e}"))?; + + svc.auth_repo + .remove_bookmark(bm_id, user_id) + .await + .map_err(|e| e.to_string()) + }) + } + + fn is_bookmarked( + &self, + session_token: &str, + content_id: String, + content_type: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + svc.auth_repo + .is_bookmarked(user_id, &content_id, &content_type) + .await + .map_err(|e| e.to_string()) + }) + } + + fn delete_account(&self, session_token: &str) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + svc.auth_repo + .delete_user_account(user_id) + .await + .map_err(|e| e.to_string()) + }) + } + + fn update_display_name( + &self, + session_token: &str, + name: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let ws = self.ws_server.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + use crate::content::websocket::ContentNotification; + svc.auth_repo + .update_display_name(user_id, name) + .await + .map_err(|e| e.to_string())?; + if let Some(ref ws_ref) = ws { + ws_ref + .notify_user( + user_id, + ContentNotification::AccountUpdated { + field: "display_name".to_string(), + }, + ) + .await; + } + Ok(()) + }) + } + + fn refresh_session(&self, session_token: &str) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let db_user = svc + .auth_repo + .find_user_by_id(&user_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("User {user_id} not found"))?; + + let user: User = db_user.into(); + svc.jwt_svc + .generate_access_token(&user) + .map_err(|e| e.to_string()) + }) + } + + fn logout_all_sessions(&self, session_token: &str) -> BoxFuture> { + let svc = self.otp_service.clone(); + let ws = self.ws_server.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + use crate::content::websocket::ContentNotification; + svc.auth_repo + .invalidate_all_user_sessions(user_id) + .await + .map_err(|e| e.to_string())?; + if let Some(ref ws_ref) = ws { + ws_ref + .notify_user( + user_id, + ContentNotification::SessionRevoked { + reason: "logout_all".to_string(), + }, + ) + .await; + } + Ok(()) + }) + } + + fn get_user_id_from_token( + &self, + session_token: &str, + ) -> BoxFuture> { + let result = Self::resolve_user_id(&self.otp_service, session_token); + Box::pin(async move { result }) + } + + fn get_session_user( + &self, + session_token: &str, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let db_user = svc + .auth_repo + .find_user_by_id(&user_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("User {user_id} not found"))?; + + Ok(rustelo_core_lib::state::UserInfo { + id: db_user.id.to_string(), + email: db_user.email, + name: db_user.display_name, + roles: db_user.roles.iter().map(|r| format!("{r:?}")).collect(), + avatar_url: db_user.avatar_url, + }) + }) + } + + fn get_messages(&self, session_token: &str) -> BoxFuture, String>> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let rows = svc + .auth_repo + .get_messages(user_id) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .into_iter() + .map(|r| UserMessage { + id: r.id.to_string(), + sender_email: r.sender_email, + subject: r.subject, + body: r.body, + is_read: r.is_read, + status: r.status.as_str().to_owned(), + created_at: r.created_at.to_rfc3339(), + received_at: r.received_at.map(|d| d.to_rfc3339()), + }) + .collect()) + }) + } + + fn mark_message_read( + &self, + session_token: &str, + message_id: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let mid = + Uuid::parse_str(&message_id).map_err(|e| format!("Invalid message ID: {e}"))?; + svc.auth_repo + .mark_message_read(mid, user_id) + .await + .map_err(|e| e.to_string()) + }) + } + + fn delete_message( + &self, + session_token: &str, + message_id: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let mid = + Uuid::parse_str(&message_id).map_err(|e| format!("Invalid message ID: {e}"))?; + svc.auth_repo + .delete_message(mid, user_id) + .await + .map_err(|e| e.to_string()) + }) + } + + fn get_notes(&self, session_token: &str) -> BoxFuture, String>> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let rows = svc + .auth_repo + .get_notes(user_id) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .into_iter() + .map(|r| UserNote { + id: r.id.to_string(), + title: r.title, + content: r.content, + created_at: r.created_at.to_rfc3339(), + updated_at: r.updated_at.to_rfc3339(), + }) + .collect()) + }) + } + + fn create_note( + &self, + session_token: &str, + title: String, + content: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let id = svc + .auth_repo + .create_note(user_id, &title, &content) + .await + .map_err(|e| e.to_string())?; + Ok(id.to_string()) + }) + } + + fn update_note( + &self, + session_token: &str, + note_id: String, + title: String, + content: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let nid = Uuid::parse_str(¬e_id).map_err(|e| format!("Invalid note ID: {e}"))?; + svc.auth_repo + .update_note(nid, user_id, &title, &content) + .await + .map_err(|e| e.to_string()) + }) + } + + fn delete_note( + &self, + session_token: &str, + note_id: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let nid = Uuid::parse_str(¬e_id).map_err(|e| format!("Invalid note ID: {e}"))?; + svc.auth_repo + .delete_note(nid, user_id) + .await + .map_err(|e| e.to_string()) + }) + } + + fn get_resources( + &self, + session_token: &str, + ) -> BoxFuture, String>> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let db_user = svc + .auth_repo + .find_user_by_id(&user_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("User {user_id} not found"))?; + + let rows = svc + .auth_repo + .get_resources_for_roles(&db_user.roles) + .await + .map_err(|e| e.to_string())?; + + Ok(rows + .into_iter() + .map(|r| UserResource { + id: r.id.to_string(), + name: r.name, + description: r.description, + url: r.url, + resource_type: r.resource_type, + role_name: r.role_name, + }) + .collect()) + }) + } + + // ── Service token management ────────────────────────────────────────── + + fn list_service_tokens( + &self, + session_token: &str, + ) -> BoxFuture, String>> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let db_user = svc + .auth_repo + .find_user_by_id(&user_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("User {user_id} not found"))?; + if !db_user.roles.iter().any(|r| r == "admin") { + return Err("Forbidden: admin role required".to_string()); + } + let rows = svc + .auth_repo + .list_service_tokens(user_id) + .await + .map_err(|e| e.to_string())?; + Ok(rows + .into_iter() + .map(|r| ServiceTokenInfo { + id: r.id.to_string(), + name: r.name, + scopes: r.scopes, + is_active: r.is_active, + last_used_at: r.last_used_at.map(|d| d.to_rfc3339()), + expires_at: r.expires_at.map(|d| d.to_rfc3339()), + created_at: r.created_at.to_rfc3339(), + }) + .collect()) + }) + } + + fn create_service_token( + &self, + session_token: &str, + name: String, + scopes: Vec, + expires_days: Option, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + use sha2::{Digest, Sha256}; + + let db_user = svc + .auth_repo + .find_user_by_id(&user_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("User {user_id} not found"))?; + if !db_user.roles.iter().any(|r| r == "admin") { + return Err("Forbidden: admin role required".to_string()); + } + + // Generate a random 32-byte token, hex-encoded. + let raw: String = { + use std::fmt::Write; + let bytes: [u8; 32] = rand::random(); + let mut s = String::with_capacity(64); + for b in bytes { + write!(s, "{b:02x}").ok(); + } + s + }; + let hash = Sha256::digest(raw.as_bytes()) + .iter() + .fold(String::new(), |mut s, b| { + use std::fmt::Write; + let _ = write!(s, "{b:02x}"); + s + }); + + let expires_at = + expires_days.map(|d| chrono::Utc::now() + chrono::Duration::days(i64::from(d))); + + let scopes_for_db = if scopes.is_empty() { + vec!["notify".to_string()] + } else { + scopes + }; + + let token_id = svc + .auth_repo + .create_service_token(&name, &hash, user_id, &scopes_for_db, expires_at) + .await + .map_err(|e| e.to_string())?; + + Ok(CreatedServiceToken { + id: token_id.to_string(), + name, + raw_token: raw, + }) + }) + } + + fn revoke_service_token( + &self, + session_token: &str, + token_id: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let db_user = svc + .auth_repo + .find_user_by_id(&user_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("User {user_id} not found"))?; + if !db_user.roles.iter().any(|r| r == "admin") { + return Err("Forbidden: admin role required".to_string()); + } + let tid = uuid::Uuid::parse_str(&token_id) + .map_err(|_| format!("Invalid token_id: {token_id}"))?; + svc.auth_repo + .revoke_service_token(tid, user_id) + .await + .map_err(|e| e.to_string()) + }) + } + + // ── Activity completions ─────────────────────────────────────────── + + fn check_activity_completion( + &self, + session_token: &str, + activity_id: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + svc.auth_repo + .check_activity_completion(user_id, &activity_id) + .await + .map_err(|e| e.to_string()) + }) + } + + fn record_activity_completion( + &self, + session_token: &str, + activity_id: String, + questionnaire_type: String, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + svc.auth_repo + .record_activity_completion(user_id, &activity_id, &questionnaire_type) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + } + + fn submit_internal_questionnaire( + &self, + session_token: &str, + activity_id: String, + responses: Vec<(String, String)>, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let user_id = match Self::resolve_user_id(&svc, session_token) { + Ok(id) => id, + Err(e) => return Box::pin(async move { Err(e) }), + }; + Box::pin(async move { + let completion_id = svc + .auth_repo + .record_activity_completion(user_id, &activity_id, "internal") + .await + .map_err(|e| e.to_string())?; + svc.auth_repo + .store_activity_responses(completion_id, &responses) + .await + .map_err(|e| e.to_string()) + }) + } + + fn record_activity_completion_by_user_id( + &self, + user_id: &str, + activity_id: String, + questionnaire_type: &str, + ) -> BoxFuture> { + let svc = self.otp_service.clone(); + let uid = match uuid::Uuid::parse_str(user_id) { + Ok(id) => id, + Err(e) => { + let msg = format!("Invalid user_id in callback: {e}"); + return Box::pin(async move { Err(msg) }); + } + }; + let qt = questionnaire_type.to_string(); + Box::pin(async move { + svc.auth_repo + .record_activity_completion(uid, &activity_id, &qt) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + } + } +} + +#[cfg(all(feature = "auth", feature = "leptos-hydration"))] +pub use adapter::OtpServiceAdapter; diff --git a/crates/foundation/crates/rustelo_server/src/auth/otp_store.rs b/crates/foundation/crates/rustelo_server/src/auth/otp_store.rs new file mode 100644 index 0000000..c83b3cc --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/auth/otp_store.rs @@ -0,0 +1,118 @@ +//! OTP token storage abstractions. +//! +//! Provides an async trait for OTP entry lifecycle and two implementations: +//! - `MemoryOtpStore`: DashMap-backed, with a background cleanup task. +//! - `DbOtpStore`: database-backed (requires `auth` feature + `otp_tokens` +//! table). + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use dashmap::DashMap; +use rustelo_core_lib::auth::AuthError; + +/// A single OTP entry stored in the backing store. +#[derive(Clone)] +pub struct OtpEntry { + pub code_hash: String, + /// Wall-clock deadline; store impls filter expired entries via this field. + pub expires_at: Instant, + /// Number of verification attempts made so far. + pub attempts: u8, + /// Whether the token has been consumed (invalidates it for further use). + pub used: bool, +} + +/// Backing store for OTP tokens. +/// +/// All methods are infallible in the memory implementation; the `Result` +/// wrapper is present to allow database implementations to propagate I/O +/// errors. +#[async_trait] +pub trait OtpStore: Send + Sync { + /// Insert (or overwrite) an OTP entry for `email`. + async fn insert( + &self, + email: &str, + code_hash: &str, + expires_at: Instant, + ) -> Result<(), AuthError>; + + /// Retrieve the entry for `email`, returning `None` if it is missing, + /// expired, or already consumed. + async fn get(&self, email: &str) -> Result, AuthError>; + + /// Atomically increment the attempt counter and return the new value. + async fn increment_attempts(&self, email: &str) -> Result; + + /// Mark the entry as consumed. Subsequent `get` calls return `None`. + async fn mark_used(&self, email: &str) -> Result<(), AuthError>; +} + +// ─── In-memory implementation +// ───────────────────────────────────────────────── + +pub struct MemoryOtpStore { + inner: Arc>, +} + +impl MemoryOtpStore { + /// Create a new store wrapped in an `Arc` and spawn a background cleanup + /// task that evicts expired / consumed entries every 60 seconds. + pub fn new_arc() -> Arc { + let inner: Arc> = Arc::new(DashMap::new()); + let cleanup = Arc::clone(&inner); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + cleanup.retain(|_, e| !e.used && e.expires_at > Instant::now()); + } + }); + Arc::new(Self { inner }) + } +} + +#[async_trait] +impl OtpStore for MemoryOtpStore { + async fn insert( + &self, + email: &str, + code_hash: &str, + expires_at: Instant, + ) -> Result<(), AuthError> { + self.inner.insert( + email.to_string(), + OtpEntry { + code_hash: code_hash.to_string(), + expires_at, + attempts: 0, + used: false, + }, + ); + Ok(()) + } + + async fn get(&self, email: &str) -> Result, AuthError> { + let entry = self + .inner + .get(email) + .filter(|e| !e.used && e.expires_at > Instant::now()) + .map(|e| e.clone()); + Ok(entry) + } + + async fn increment_attempts(&self, email: &str) -> Result { + let mut entry = self.inner.get_mut(email).ok_or(AuthError::InvalidToken)?; + entry.attempts += 1; + Ok(entry.attempts) + } + + async fn mark_used(&self, email: &str) -> Result<(), AuthError> { + if let Some(mut entry) = self.inner.get_mut(email) { + entry.used = true; + } + Ok(()) + } +} diff --git a/crates/foundation/crates/rustelo_server/src/auth/password.rs b/crates/foundation/crates/rustelo_server/src/auth/password.rs index c02b5be..a1a5fe6 100644 --- a/crates/foundation/crates/rustelo_server/src/auth/password.rs +++ b/crates/foundation/crates/rustelo_server/src/auth/password.rs @@ -2,8 +2,6 @@ use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; -use rand::Rng; - #[derive(Debug, Clone)] pub struct PasswordService { argon2: Argon2<'static>, @@ -54,11 +52,9 @@ impl PasswordService { pub fn generate_random_password(&self, length: usize) -> String { const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; - let mut rng = rand::rng(); - (0..length) .map(|_| { - let idx = rng.random_range(0..CHARSET.len()); + let idx = (rand::random::() as usize) % CHARSET.len(); CHARSET[idx] as char }) .collect() @@ -67,10 +63,8 @@ impl PasswordService { /// Generate a secure random token (for password reset, email verification, /// etc.) pub fn generate_secure_token(&self) -> String { - use rand::RngCore; - let mut rng = rand::rng(); let mut bytes = [0u8; 32]; - rng.fill_bytes(&mut bytes); + rand::fill(&mut bytes[..]); hex::encode(bytes) } diff --git a/crates/foundation/crates/rustelo_server/src/auth/routes.rs b/crates/foundation/crates/rustelo_server/src/auth/routes.rs index 36078b4..7ffe692 100644 --- a/crates/foundation/crates/rustelo_server/src/auth/routes.rs +++ b/crates/foundation/crates/rustelo_server/src/auth/routes.rs @@ -573,6 +573,111 @@ pub async fn generate_backup_codes( } } +// ─── OTP / Passwordless +// ─────────────────────────────────────────────────────── + +#[derive(serde::Deserialize)] +pub struct OtpRequestBody { + pub email: String, +} + +#[derive(serde::Deserialize)] +pub struct OtpVerifyBody { + pub email: String, + pub code: String, +} + +pub async fn request_otp( + State(auth_service): State>, + headers: axum::http::HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + let otp = match &auth_service.otp_service { + Some(svc) => svc, + None => { + return Json(ApiResponse::<()>::error( + "OTP authentication is not configured".to_string(), + )) + .into_response(); + } + }; + // Derive language from Accept-Language header (first tag, language subtag + // only). + let lang_owned = headers + .get(axum::http::header::ACCEPT_LANGUAGE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .and_then(|tag| tag.split(';').next()) + .and_then(|tag| tag.split('-').next()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_else(|| "en".to_string()); + let lang = lang_owned.as_str(); + let cfg = &auth_service.otp_config; + match otp + .request_otp( + &body.email, + cfg.otp_code_length, + cfg.otp_expiry_minutes, + lang, + ) + .await + { + Ok(()) => Json(ApiResponse::success_with_message( + (), + "Verification code sent".to_string(), + )) + .into_response(), + Err(err) => { + use crate::auth::middleware::auth_error_response; + auth_error_response(err) + } + } +} + +pub async fn verify_otp( + State(auth_service): State>, + Json(body): Json, +) -> impl IntoResponse { + let otp = match &auth_service.otp_service { + Some(svc) => svc, + None => { + return Json(ApiResponse::<()>::error( + "OTP authentication is not configured".to_string(), + )) + .into_response(); + } + }; + use crate::auth::otp::OtpVerifyOutcome; + let max_attempts = auth_service.otp_config.otp_max_attempts; + match otp.verify_otp(&body.email, &body.code, max_attempts).await { + Ok(OtpVerifyOutcome::Authenticated(auth_response)) => Json( + ApiResponse::success_with_message(auth_response, "Authenticated".to_string()), + ) + .into_response(), + Ok(OtpVerifyOutcome::NeedsConsent { pending_token }) => { + #[derive(serde::Serialize)] + struct NeedsConsentPayload { + needs_consent: bool, + pending_token: String, + } + Json(ApiResponse::success_with_message( + NeedsConsentPayload { + needs_consent: true, + pending_token, + }, + "GDPR consent required".to_string(), + )) + .into_response() + } + Err(err) => { + use crate::auth::middleware::auth_error_response; + auth_error_response(err) + } + } +} + /// Create authentication routes pub fn create_auth_routes() -> axum::Router> { use axum::routing::{get, post, put}; @@ -587,8 +692,11 @@ pub fn create_auth_routes() -> axum::Router> { .route("/password-reset/request", post(request_password_reset)) .route("/password-reset/confirm", post(confirm_password_reset)) .route("/oauth/providers", get(oauth_providers)) - .route("/oauth/:provider/authorize", get(oauth_authorize)) - .route("/oauth/:provider/callback", get(oauth_callback)) + .route("/oauth/{provider}/authorize", get(oauth_authorize)) + .route("/oauth/{provider}/callback", get(oauth_callback)) + // OTP / Passwordless routes + .route("/otp/request", post(request_otp)) + .route("/otp/verify", post(verify_otp)) // Protected routes (require authentication) .route("/status", get(auth_status)) .route("/profile", get(get_profile)) @@ -602,8 +710,8 @@ pub fn create_auth_routes() -> axum::Router> { .route("/2fa/disable", post(disable_2fa)) .route("/2fa/backup-codes", post(generate_backup_codes)) // Admin routes (require admin role) - .route("/admin/users/:user_id", get(get_user)) - .route("/admin/users/:user_id/verify-email", post(verify_email)) + .route("/admin/users/{user_id}", get(get_user)) + .route("/admin/users/{user_id}/verify-email", post(verify_email)) .route("/admin/cleanup", post(cleanup_expired)) } diff --git a/crates/foundation/crates/rustelo_server/src/auth/service.rs b/crates/foundation/crates/rustelo_server/src/auth/service.rs index 839a221..22d9a1a 100644 --- a/crates/foundation/crates/rustelo_server/src/auth/service.rs +++ b/crates/foundation/crates/rustelo_server/src/auth/service.rs @@ -15,10 +15,12 @@ use uuid::Uuid; use super::{ jwt::JwtService, oauth::{OAuthAuthorizationUrl, OAuthCallback, OAuthService}, + otp::OtpService, password::PasswordService, repository::{AuthRepository, AuthRepositoryTrait, CreateSessionRequest, CreateUserRequest}, two_factor::TwoFactorService, }; +use crate::config::AuthOtpConfig; #[derive(Clone)] pub struct AuthService { @@ -27,6 +29,9 @@ pub struct AuthService { pub password_service: Arc, pub repository: Arc, pub two_factor_service: Arc, + /// Present when OTP auth is configured. + pub otp_service: Option>, + pub otp_config: AuthOtpConfig, } impl AuthService { @@ -36,6 +41,8 @@ impl AuthService { password_service: Arc, repository: Arc, two_factor_service: Arc, + otp_service: Option>, + otp_config: AuthOtpConfig, ) -> Self { Self { jwt_service, @@ -43,6 +50,8 @@ impl AuthService { password_service, repository, two_factor_service, + otp_service, + otp_config, } } @@ -495,7 +504,7 @@ impl AuthService { .map_err(|_| AuthError::InternalError)?; // Find token - let (user_id, expires_at) = self + let (user_id, _expires_at) = self .repository .find_token(&token_hash, "password_reset") .await diff --git a/crates/foundation/crates/rustelo_server/src/auth/two_factor.rs b/crates/foundation/crates/rustelo_server/src/auth/two_factor.rs index f288d4c..0c3a8a4 100644 --- a/crates/foundation/crates/rustelo_server/src/auth/two_factor.rs +++ b/crates/foundation/crates/rustelo_server/src/auth/two_factor.rs @@ -22,6 +22,7 @@ pub struct TwoFactorService { issuer: String, } +#[allow(dead_code)] #[derive(Debug, Serialize, Deserialize)] struct User2FA { id: Uuid, @@ -279,11 +280,7 @@ impl TwoFactorService { ) -> Result { // Generate 8 random backup codes let backup_codes: Vec = (0..8) - .map(|_| { - use rand::Rng; - let mut rng = rand::rng(); - format!("{:08}", rng.random_range(10000000..99999999)) - }) + .map(|_| format!("{:08}", 10_000_000_u32 + rand::random::() % 89_999_999)) .collect(); if let Some(ref database) = self.database { @@ -313,11 +310,7 @@ impl TwoFactorService { ) -> Result { // Generate 8 random backup codes let backup_codes: Vec = (0..8) - .map(|_| { - use rand::Rng; - let mut rng = rand::rng(); - format!("{:08}", rng.random_range(10000000..99999999)) - }) + .map(|_| format!("{:08}", 10_000_000_u32 + rand::random::() % 89_999_999)) .collect(); if let Some(ref database) = self.database { @@ -421,7 +414,7 @@ impl TwoFactorService { /// Verify 2FA code for login pub async fn verify_2fa_for_login( &self, - user_id: Uuid, + _user_id: Uuid, code: &str, _ip_address: Option, _user_agent: Option, diff --git a/crates/foundation/crates/rustelo_server/src/bin/content_processor.rs b/crates/foundation/crates/rustelo_server/src/bin/content_processor.rs index 0b09e2a..8143612 100644 --- a/crates/foundation/crates/rustelo_server/src/bin/content_processor.rs +++ b/crates/foundation/crates/rustelo_server/src/bin/content_processor.rs @@ -368,6 +368,11 @@ fn process_content_type_language( // Process all markdown files recursively process_directory_recursive(&source_dir, &output_dir, &mut posts, "")?; + // Remove rendered outputs whose source markdown no longer exists (posts that + // were renamed or deleted). Scoped to this (type, language) output subtree, so + // a targeted --content-type/--language run only prunes within its own scope. + prune_orphaned_outputs(&source_dir, &output_dir)?; + // Filter published posts posts.retain(|post| post.frontmatter.published.unwrap_or(true)); @@ -440,6 +445,58 @@ fn process_directory_recursive( Ok(()) } +/// Remove rendered outputs orphaned by a renamed or deleted source file. +/// +/// Each post emits the pair `.html` + `.json`, named after its +/// source `.md`. Aggregates (`index.json`, `filter-index.json`, category +/// indexes) are `.json` files with no `.html` sibling, so anchoring the sweep +/// on `.html` files leaves them untouched by construction — no reserved-name +/// allowlist needed. The output tree mirrors the source tree, so +/// `/.html` maps back to `//.md`; a missing +/// source removes the pair. Emptied mirror directories (whose source directory +/// is gone) are removed as well. +fn prune_orphaned_outputs(source_dir: &Path, output_dir: &Path) -> Result<()> { + if !output_dir.exists() { + return Ok(()); + } + + for entry in fs::read_dir(output_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + let dir_name = path + .file_name() + .and_then(|s| s.to_str()) + .context("Invalid directory name")?; + let source_subdir = source_dir.join(dir_name); + + prune_orphaned_outputs(&source_subdir, &path)?; + + // Drop the mirror directory once it is empty and its source is gone. + if !source_subdir.exists() && fs::read_dir(&path)?.next().is_none() { + fs::remove_dir(&path)?; + } + } else if path.extension().and_then(|s| s.to_str()) == Some("html") { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .context("Invalid output filename")?; + + if !source_dir.join(format!("{stem}.md")).exists() { + fs::remove_file(&path)?; + let json_sibling = path.with_extension("json"); + if json_sibling.exists() { + fs::remove_file(&json_sibling)?; + } + println!(" 🗑️ Pruned orphaned output: {}", path.display()); + } + } + } + + Ok(()) +} + /// Process specific files by pattern fn process_specific_files(config: &ProcessorConfig, file_pattern: &str) -> Result<()> { println!("🎯 Processing specific files matching: {}", file_pattern); diff --git a/crates/foundation/crates/rustelo_server/src/config/config.rs b/crates/foundation/crates/rustelo_server/src/config/config.rs index 98a8c41..9fe02a4 100644 --- a/crates/foundation/crates/rustelo_server/src/config/config.rs +++ b/crates/foundation/crates/rustelo_server/src/config/config.rs @@ -4,13 +4,11 @@ //! methods. use std::env; -use std::fs; use std::path::{Path, PathBuf}; use dotenv; use regex; use serde::{Deserialize, Serialize}; -use toml; use super::*; @@ -39,6 +37,25 @@ pub struct Config { #[serde(default)] pub routing: RoutingConfig, + /// Navigation and application log paths (from `site/config/logs.ncl`). + #[serde(default)] + pub logs: super::LogsConfig, + + /// File-based RBAC policy configuration (optional, feature-gated) + #[cfg(feature = "rbac")] + #[serde(default)] + pub rbac: super::RbacNclConfig, + + /// Event pipeline configuration (path to `site/pipelines/index.ncl`). + #[cfg(feature = "nats-admin")] + #[serde(default)] + pub pipelines: super::PipelinesNclConfig, + + /// External services configuration (NATS admin, etc.) from + /// `external-services.ncl`. + #[serde(default)] + pub external_services: super::ExternalServicesConfig, + // Encryption instance (not serialized) #[serde(skip)] #[cfg(feature = "crypto")] @@ -63,6 +80,7 @@ impl Default for Config { connect_timeout: 30, idle_timeout: 600, max_lifetime: 1800, + create_if_missing: true, }, session: SessionConfig { secret: "change-this-in-production-to-a-secure-random-string".to_string(), @@ -172,72 +190,72 @@ impl Default for Config { }, cache: super::ServerCacheConfig::default(), features: super::FeatureConfig::default(), + #[cfg(feature = "rbac")] + rbac: super::RbacNclConfig::default(), + #[cfg(feature = "nats-admin")] + pipelines: super::PipelinesNclConfig::default(), #[cfg(feature = "crypto")] encryption: None, root_path: default_root_path(), routing: RoutingConfig::default(), + logs: super::LogsConfig::default(), + external_services: super::ExternalServicesConfig::default(), } } } impl Config { - /// Load configuration from TOML file with environment variable overrides + /// Load configuration from the unified NCL config file. + /// + /// Resolution order for the config path: + /// 1. `SITE_CONFIG_PATH` env var + /// 2. `/site/config/config.ncl` (project-local default) + /// + /// The `nickel` binary must be available in `PATH` at runtime. pub fn load() -> Result { - // Initialize path utilities first - // No initialization needed with env-based approach - - // Load .env file if it exists dotenv::dotenv().ok(); - // Try to determine configuration file path - let mut config = match Self::determine_config_file() { - Ok(config_file) => { - // Load and parse TOML file - Self::load_from_file(&config_file)? - } - Err(_) => { - // No config file found, create default and optionally save it - let default_config = Self::create_default_config_for_environment()?; - - // Try to save default config if enabled - let auto_create = std::env::var("AUTO_CREATE_CONFIG") - .unwrap_or_else(|_| "true".to_string()) - .to_lowercase(); - - if auto_create == "true" || auto_create == "1" { - if let Ok(env) = std::env::var("ENVIRONMENT") { - if env.to_lowercase() == "development" || env.to_lowercase() == "dev" { - if let Err(e) = Self::save_default_config(&default_config) { - eprintln!("Warning: Could not save default config: {e}"); - } - } - } + let ncl_path = env::var("SITE_CONFIG_PATH") + .ok() + .map(PathBuf::from) + .and_then(|p| { + if p.is_absolute() { + Some(p) + } else { + env::current_dir().ok().map(|d| d.join(p)) } - default_config - } - }; + }) + .unwrap_or_else(|| { + env::current_dir() + .ok() + .map(|d| d.join("site/config/config.ncl")) + .unwrap_or_else(|| PathBuf::from("site/config/config.ncl")) + }); - // Initialize encryption system + let json = rustelo_config::nickel::export_to_json(&ncl_path).map_err(|e| { + ConfigError::ReadError(format!( + "NCL config export failed for '{}': {}\nEnsure `nickel` is in PATH and \ + SITE_CONFIG_PATH points to site/config.ncl", + ncl_path.display(), + e + )) + })?; + + let mut config: Self = serde_json::from_str(&json) + .map_err(|e| ConfigError::ParseError(format!("Config deserialisation failed: {e}")))?; + + // Initialize encryption using root_path resolved from env (via serde default). #[cfg(feature = "crypto")] { config.encryption = Some(super::ConfigEncryption::new(&config.root_path).map_err( - |e| ConfigError::EncryptionError(format!("Failed to initialize encryption: {}", e)), + |e| ConfigError::EncryptionError(format!("Encryption init failed: {e}")), )?); } - // Apply environment variable overrides config = Self::apply_env_overrides(config)?; - - // Substitute environment variables in string values config = Self::substitute_env_vars(config)?; - - // Decrypt encrypted values config = Self::decrypt_encrypted_values(config)?; - - // Resolve relative paths to absolute paths config = Self::resolve_paths(config)?; - - // Validate configuration config.validate()?; Ok(config) @@ -285,6 +303,9 @@ impl Config { // Resolve logging file path config.logging.file_path = Self::resolve_path(&root_path, &config.logging.file_path)?; + // Resolve navigation log directory + config.logs.nav_log_dir = Self::resolve_path(&root_path, &config.logs.nav_log_dir)?; + // Resolve content directory path if content feature is enabled #[cfg(feature = "content-db")] { @@ -318,62 +339,6 @@ impl Config { Self::resolve_path(&root_path, relative_path) } - /// Determine which configuration file to use - fn determine_config_file() -> Result { - // Check for explicit config file path - if let Ok(config_path) = env::var("CONFIG_FILE") { - return Ok(PathBuf::from(config_path)); - } - - // Check for environment-specific config - let env = env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()); - let env_config = match env.to_lowercase().as_str() { - "production" | "prod" => "config.prod.toml", - "development" | "dev" => "config.dev.toml", - _ => "config.toml", - }; - - // Look for config file in current directory first, then in parent directories - let mut current_dir = env::current_dir() - .map_err(|e| ConfigError::ReadError(format!("Failed to get current directory: {e}")))?; - - loop { - let config_path = current_dir.join(env_config); - if config_path.exists() { - return Ok(config_path); - } - - // Try the default config.toml if environment-specific doesn't exist - if env_config != "config.toml" { - let default_path = current_dir.join("config.toml"); - if default_path.exists() { - return Ok(default_path); - } - } - - // Move up one directory - if let Some(parent) = current_dir.parent() { - current_dir = parent.to_path_buf(); - } else { - break; - } - } - - Err(ConfigError::MissingFile(format!( - "Configuration file '{env_config}' not found. Will use default configuration." - ))) - } - - /// Load configuration from a specific file - pub fn load_from_file(path: &PathBuf) -> Result { - let contents = fs::read_to_string(path).map_err(|e| { - ConfigError::ReadError(format!("Failed to read {}: {}", path.display(), e)) - })?; - - toml::from_str(&contents) - .map_err(|e| ConfigError::ParseError(format!("Failed to parse TOML: {e}"))) - } - /// Apply environment variable overrides to configuration fn apply_env_overrides(mut config: Self) -> Result { // Server overrides @@ -411,9 +376,41 @@ impl Config { } } - // Database overrides + // Database overrides — priority: DATABASE_URL > NCL_DATABASE_URL > TOML default if let Ok(database_url) = env::var("DATABASE_URL") { config.database.url = database_url; + } else if let Ok(ncl_url) = env::var("NCL_DATABASE_URL") { + config.database.url = ncl_url; + } + + // NCL pool/behaviour overrides — applied regardless of URL source + if let Ok(val) = env::var("NCL_DATABASE_CREATE_IF_MISSING") { + config.database.create_if_missing = val != "false" && val != "0"; + } + if let Ok(val) = env::var("NCL_DATABASE_MAX_CONNECTIONS") { + if let Ok(n) = val.parse::() { + config.database.max_connections = n; + } + } + if let Ok(val) = env::var("NCL_DATABASE_MIN_CONNECTIONS") { + if let Ok(n) = val.parse::() { + config.database.min_connections = n; + } + } + if let Ok(val) = env::var("NCL_DATABASE_CONNECT_TIMEOUT") { + if let Ok(n) = val.parse::() { + config.database.connect_timeout = n; + } + } + if let Ok(val) = env::var("NCL_DATABASE_IDLE_TIMEOUT") { + if let Ok(n) = val.parse::() { + config.database.idle_timeout = n; + } + } + if let Ok(val) = env::var("NCL_DATABASE_MAX_LIFETIME") { + if let Ok(n) = val.parse::() { + config.database.max_lifetime = n; + } } // Session overrides @@ -426,6 +423,12 @@ impl Config { config.root_path = root_path; } + // Nav log directory override — takes precedence over logs.ncl value. + // resolve_paths() will make it absolute afterwards. + if let Ok(nav_log_dir) = env::var("RUSTELO_NAV_LOG_DIR") { + config.logs.nav_log_dir = nav_log_dir; + } + Ok(config) } @@ -437,9 +440,14 @@ impl Config { // Session secret substitution config.session.secret = Self::substitute_env_in_string(&config.session.secret); - // Email configuration substitution + // Email configuration substitution. Cover host + sender fields too, not + // just the credentials, so `${SMTP_HOST}` / `${EMAIL_FROM}` in email.ncl + // resolve from the environment like the username/password do. + config.email.smtp_host = Self::substitute_env_in_string(&config.email.smtp_host); config.email.smtp_username = Self::substitute_env_in_string(&config.email.smtp_username); config.email.smtp_password = Self::substitute_env_in_string(&config.email.smtp_password); + config.email.from_email = Self::substitute_env_in_string(&config.email.from_email); + config.email.from_name = Self::substitute_env_in_string(&config.email.from_name); config.email.sendgrid_api_key = Self::substitute_env_in_string(&config.email.sendgrid_api_key); @@ -456,6 +464,26 @@ impl Config { // Redis URL substitution config.redis.url = Self::substitute_env_in_string(&config.redis.url); + // NATS admin substitution + if let Some(ref mut na) = config.external_services.nats_admin { + na.url = Self::substitute_env_in_string(&na.url); + na.credentials_file = Self::substitute_env_in_string(&na.credentials_file); + na.namespace.prefix = Self::substitute_env_in_string(&na.namespace.prefix); + na.namespace.env = Self::substitute_env_in_string(&na.namespace.env); + na.nkey_seed = na + .nkey_seed + .as_deref() + .map(|s| Self::substitute_env_in_string(s)); + } + + // Pipeline subsystem substitution + #[cfg(feature = "nats-admin")] + { + config.pipelines.ncl_path = Self::substitute_env_in_string(&config.pipelines.ncl_path); + config.pipelines.scripts_root = + Self::substitute_env_in_string(&config.pipelines.scripts_root); + } + Ok(config) } @@ -477,7 +505,8 @@ impl Config { } /// Decrypt encrypted configuration values (values starting with '@') - fn decrypt_encrypted_values(config: Self) -> Result { + #[cfg_attr(not(feature = "crypto"), allow(unused_mut))] + fn decrypt_encrypted_values(mut config: Self) -> Result { #[cfg(feature = "crypto")] if let Some(ref encryption) = config.encryption { // Decrypt database configuration @@ -658,51 +687,6 @@ impl Config { } } - /// Create a default configuration for the current environment - fn create_default_config_for_environment() -> Result { - let env = std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()); - - let mut config = Self::default(); - - // Adjust config based on environment - match env.to_lowercase().as_str() { - "production" | "prod" => { - config.server.environment = Environment::Production; - config.server.log_level = "info".to_string(); - config.app.debug = false; - config.security.enable_csrf = true; - config.session.cookie_secure = true; - config.logging.level = "info".to_string(); - config.logging.format = "json".to_string(); - } - _ => { - config.server.environment = Environment::Development; - config.server.log_level = "debug".to_string(); - config.app.debug = true; - config.security.enable_csrf = false; - config.session.cookie_secure = false; - config.logging.level = "debug".to_string(); - config.logging.format = "pretty".to_string(); - - // Default to SQLite for easy development setup - config.database.url = "sqlite:data/development.db".to_string(); - } - } - - let auto_create = std::env::var("AUTO_CREATE_CONFIG") - .unwrap_or_else(|_| "true".to_string()) - .to_lowercase(); - - if auto_create == "true" || auto_create == "1" { - eprintln!("Using default configuration for {env} environment"); - } else { - eprintln!( - "Using default configuration for {env} environment (config file creation disabled)" - ); - } - Ok(config) - } - /// Create a shared CacheConfig with the proper cache directory path #[allow(dead_code)] pub fn create_content_cache_config(&self) -> rustelo_core_lib::cache::CacheConfig { @@ -715,26 +699,4 @@ impl Config { ..Default::default() } } - - /// Save default configuration to file - fn save_default_config(config: &Self) -> Result<(), ConfigError> { - let env = std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()); - let filename = match env.to_lowercase().as_str() { - "production" | "prod" => "config.prod.toml", - "development" | "dev" => "config.dev.toml", - _ => "config.toml", - }; - - let toml_content = toml::to_string_pretty(config) - .map_err(|e| ConfigError::ParseError(format!("Failed to serialize config: {e}")))?; - - std::fs::write(filename, toml_content) - .map_err(|e| ConfigError::ReadError(format!("Failed to write config file: {e}")))?; - - eprintln!( - "Created default configuration file: {filename} (set AUTO_CREATE_CONFIG=false to \ - disable)" - ); - Ok(()) - } } diff --git a/crates/foundation/crates/rustelo_server/src/config/defs.rs b/crates/foundation/crates/rustelo_server/src/config/defs.rs index 07697da..9c51cd6 100644 --- a/crates/foundation/crates/rustelo_server/src/config/defs.rs +++ b/crates/foundation/crates/rustelo_server/src/config/defs.rs @@ -20,6 +20,10 @@ pub struct ServerConfig { pub asset_mode: rustelo_core_lib::AssetMode, } +fn default_create_if_missing() -> bool { + true +} + /// Database configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DatabaseConfig { @@ -29,6 +33,10 @@ pub struct DatabaseConfig { pub connect_timeout: u64, pub idle_timeout: u64, pub max_lifetime: u64, + /// Create the database file on first use (SQLite only; ignored for + /// PostgreSQL). + #[serde(default = "default_create_if_missing")] + pub create_if_missing: bool, } /// Session configuration @@ -330,6 +338,7 @@ impl Default for DatabaseConfig { connect_timeout: 30, idle_timeout: 600, max_lifetime: 1800, + create_if_missing: true, } } } @@ -492,6 +501,350 @@ impl Default for CacheConfig { } } +/// File-based RBAC policy configuration. +#[cfg(feature = "rbac")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RbacNclConfig { + /// Path to the `rbac.ncl` policy file. + /// Supports `{SITE_CONTENT_PATH}` — resolved at load time via + /// `default_rbac_ncl_path`. + #[serde(default = "default_rbac_ncl_path")] + pub ncl_path: String, +} + +#[cfg(feature = "rbac")] +fn default_rbac_ncl_path() -> String { + std::env::var("RBAC_NCL_PATH").unwrap_or_else(|_| "./site/rbac.ncl".to_string()) +} + +#[cfg(feature = "rbac")] +impl Default for RbacNclConfig { + fn default() -> Self { + Self { + ncl_path: default_rbac_ncl_path(), + } + } +} + +/// Pipeline event system configuration. +#[cfg(feature = "nats-admin")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelinesNclConfig { + /// Path to the pipeline index NCL file. + /// Supports `PIPELINES_NCL_PATH` env-var override. + /// Default: `./site/pipelines/index.ncl` + #[serde(default = "default_pipelines_ncl_path")] + pub ncl_path: String, + + /// Root directory from which relative script paths in pipeline steps are + /// resolved. Supports `PIPELINE_SCRIPTS_ROOT` env-var override. + /// Default: `./site/scripts` + #[serde(default = "default_pipeline_scripts_root")] + pub scripts_root: String, +} + +#[cfg(feature = "nats-admin")] +fn default_pipelines_ncl_path() -> String { + std::env::var("PIPELINES_NCL_PATH").unwrap_or_else(|_| "./site/pipelines/index.ncl".to_string()) +} + +#[cfg(feature = "nats-admin")] +fn default_pipeline_scripts_root() -> String { + std::env::var("PIPELINE_SCRIPTS_ROOT").unwrap_or_else(|_| "./site/scripts".to_string()) +} + +#[cfg(feature = "nats-admin")] +impl Default for PipelinesNclConfig { + fn default() -> Self { + Self { + ncl_path: default_pipelines_ncl_path(), + scripts_root: default_pipeline_scripts_root(), + } + } +} + +/// OTP / Passwordless auth configuration (loaded from site/config/auth.toml) +#[cfg(feature = "auth")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthOtpConfig { + #[serde(default = "default_login_mode")] + pub login_mode: LoginMode, + #[serde(default = "default_otp_store_kind")] + pub otp_store: OtpStoreKind, + #[serde(default = "default_otp_expiry")] + pub otp_expiry_minutes: u64, + #[serde(default = "default_otp_max_attempts")] + pub otp_max_attempts: u8, + #[serde(default = "default_otp_code_length")] + pub otp_code_length: u8, + #[serde(default = "default_registration_role")] + pub registration_role: String, +} + +#[cfg(feature = "auth")] +fn default_login_mode() -> LoginMode { + LoginMode::Modal +} +#[cfg(feature = "auth")] +fn default_otp_store_kind() -> OtpStoreKind { + OtpStoreKind::Memory +} +#[cfg(feature = "auth")] +fn default_otp_expiry() -> u64 { + 10 +} +#[cfg(feature = "auth")] +fn default_otp_max_attempts() -> u8 { + 3 +} +#[cfg(feature = "auth")] +fn default_otp_code_length() -> u8 { + 6 +} +#[cfg(feature = "auth")] +fn default_registration_role() -> String { + "user".to_string() +} + +#[cfg(feature = "auth")] +impl Default for AuthOtpConfig { + fn default() -> Self { + Self { + login_mode: default_login_mode(), + otp_store: default_otp_store_kind(), + otp_expiry_minutes: default_otp_expiry(), + otp_max_attempts: default_otp_max_attempts(), + otp_code_length: default_otp_code_length(), + registration_role: default_registration_role(), + } + } +} + +#[cfg(feature = "auth")] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum LoginMode { + Modal, + Page, +} + +#[cfg(feature = "auth")] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum OtpStoreKind { + Memory, + Db, +} + +/// Wrapper to parse [auth] section from auth.toml +#[cfg(feature = "auth")] +#[derive(Debug, Deserialize)] +struct AuthTomlWrapper { + auth: AuthOtpConfig, +} + +/// Load AuthOtpConfig from a TOML file path, falling back to defaults on any +/// error. +#[cfg(feature = "auth")] +pub fn load_auth_otp_config(path: &std::path::Path) -> AuthOtpConfig { + std::fs::read_to_string(path) + .ok() + .and_then(|s| toml::from_str::(&s).ok()) + .map(|w| w.auth) + .unwrap_or_default() +} + +/// Navigation and application log paths loaded from `site/config/logs.ncl`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogsConfig { + /// Base directory for per-page navigation JSONL logs. + /// Relative paths are resolved against `root_path` at startup. + /// Overridable at runtime via `RUSTELO_NAV_LOG_DIR` env var. + pub nav_log_dir: String, +} + +impl Default for LogsConfig { + fn default() -> Self { + Self { + nav_log_dir: "logs/navigation".to_string(), + } + } +} + +/// NATS admin namespace — prefix + env assembled into full subject paths at +/// runtime. +/// +/// Full subject path: `{prefix}.{env}.{leaf_subject}` +/// e.g. `evol.website.prod.admin.logs` +/// +/// `env` can be overridden per k8s Deployment via `NATS_ADMIN_NAMESPACE_ENV` +/// without changing the NCL file. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NatsAdminNamespaceConfig { + pub prefix: String, + pub env: String, +} + +/// JetStream consumer definition — one pull consumer on the admin stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NatsAdminConsumerConfig { + /// Durable consumer name (unique within the stream). + pub name: String, + /// Ack policy: `"Explicit"` | `"None"` | `"All"`. Default: `"Explicit"`. + #[serde(default = "NatsAdminConsumerConfig::default_ack_policy")] + pub ack_policy: String, + /// Optional subject filters. Empty list means the consumer receives all + /// subjects on the stream. + #[serde(default)] + pub filter_subjects: Vec, +} + +impl NatsAdminConsumerConfig { + fn default_ack_policy() -> String { + "Explicit".to_string() + } +} + +impl Default for NatsAdminConsumerConfig { + fn default() -> Self { + Self { + name: "rustelo-pipelines".to_string(), + ack_policy: Self::default_ack_policy(), + filter_subjects: Vec::new(), + } + } +} + +/// JetStream stream + consumer configuration for the admin event stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NatsAdminJetStreamConfig { + pub enabled: bool, + /// Stream name in JetStream. + pub stream_name: String, + /// Retention period in days (0 = unlimited). + pub retention_days: u32, + /// Retention policy: `"Limits"` | `"Interest"` | `"WorkQueue"`. + #[serde(default = "NatsAdminJetStreamConfig::default_retention")] + pub retention: String, + /// Storage backend: `"File"` | `"Memory"`. + #[serde(default = "NatsAdminJetStreamConfig::default_storage")] + pub storage: String, + /// Max messages in the stream (0 = unlimited). + #[serde(default)] + pub max_messages: i64, + /// Max bytes in the stream (0 = unlimited). + #[serde(default)] + pub max_bytes: i64, + /// Pull consumers to create on this stream. + #[serde(default = "NatsAdminJetStreamConfig::default_consumers")] + pub consumers: Vec, +} + +impl NatsAdminJetStreamConfig { + fn default_retention() -> String { + "Limits".to_string() + } + fn default_storage() -> String { + "File".to_string() + } + fn default_consumers() -> Vec { + vec![NatsAdminConsumerConfig::default()] + } + + /// Build the list of subjects for the JetStream stream. + /// + /// Uses a wildcard pattern covering the full namespace so all pipeline + /// subjects (`content.*`, `deploy.*`, `user.*`, etc.) are captured by + /// a single stream. + /// + /// Pattern: `{prefix}.{env}.>` (NATS `>` wildcard matches everything) + pub fn pipeline_subjects(&self, namespace: &NatsAdminNamespaceConfig) -> Vec { + vec![format!("{}.{}.>", namespace.prefix, namespace.env)] + } +} + +impl Default for NatsAdminJetStreamConfig { + fn default() -> Self { + Self { + enabled: true, + stream_name: "rustelo-admin".to_string(), + retention_days: 30, + retention: Self::default_retention(), + storage: Self::default_storage(), + max_messages: 0, + max_bytes: 0, + consumers: Self::default_consumers(), + } + } +} + +/// Admin NATS connection — separate from the general app NATS. +/// +/// Uses nkeys credentials file (mounted as k8s Secret) instead of a plain +/// token. Leaf-node topology: the web server connects outbound to the hub; +/// no inbound ports required in k8s. +/// +/// Sensitive fields (`url`, `credentials_file`, `namespace.*`, `nkey_seed`) +/// support `${ENV_VAR}` substitution resolved at startup. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NatsAdminConnectionConfig { + pub enabled: bool, + pub url: String, + pub credentials_file: String, + pub connection_timeout: u64, + pub max_reconnects: usize, + pub reconnect_delay: u64, + pub namespace: NatsAdminNamespaceConfig, + /// Leaf subject names (without namespace prefix). Open map — + /// implementations may extend. Assembled at runtime: + /// `{namespace.prefix}.{namespace.env}.{leaf}` + pub subjects: std::collections::HashMap, + pub jetstream: NatsAdminJetStreamConfig, + /// NKey seed for signing published messages (ed25519). + /// Use `${NATS_ADMIN_NKEY_SEED}` to read from env. + /// When `None`, messages are published unsigned. + #[serde(default)] + pub nkey_seed: Option, + /// When `true`, incoming pipeline messages without a valid NKey signature + /// are dropped and nack'd. Only meaningful when `trusted_nkeys` is + /// non-empty. + #[serde(default)] + pub require_signed_messages: bool, + /// NKey public keys trusted to sign pipeline messages. + /// Empty list disables signature verification. + #[serde(default)] + pub trusted_nkeys: Vec, +} + +impl Default for NatsAdminConnectionConfig { + fn default() -> Self { + Self { + enabled: false, + url: String::new(), + credentials_file: String::new(), + connection_timeout: 5, + max_reconnects: 10, + reconnect_delay: 2, + namespace: NatsAdminNamespaceConfig::default(), + subjects: std::collections::HashMap::new(), + jetstream: NatsAdminJetStreamConfig::default(), + nkey_seed: None, + require_signed_messages: false, + trusted_nkeys: Vec::new(), + } + } +} + +/// Deserialized `external_services` block from the unified NCL config. +/// +/// Only `nats_admin` is captured here; other services (`nats`, `ollama`, `s3`) +/// are silently ignored by serde unless their own structs are added. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ExternalServicesConfig { + #[serde(default)] + pub nats_admin: Option, +} + /// Database pool configuration helper #[derive(Debug, Clone)] #[allow(dead_code)] diff --git a/crates/foundation/crates/rustelo_server/src/config/encryption.rs b/crates/foundation/crates/rustelo_server/src/config/encryption.rs index b5d7839..ad067a4 100644 --- a/crates/foundation/crates/rustelo_server/src/config/encryption.rs +++ b/crates/foundation/crates/rustelo_server/src/config/encryption.rs @@ -82,15 +82,15 @@ impl ConfigEncryption { )); } - let key = Key::::from_slice(&key_bytes); + let key_arr: [u8; 32] = key_bytes.as_slice().try_into().expect("validated 32 bytes"); debug!("Loaded encryption key from {:?}", key_path); - Ok(*key) + Ok(Key::::from(key_arr)) } /// Create a new encryption key and save it to file fn create_new_key(key_path: &PathBuf) -> Result, ConfigError> { let key = Aes256Gcm::generate_key(&mut OsRng); - let key_b64 = STANDARD.encode(key.as_slice()); + let key_b64 = STANDARD.encode(key.as_ref() as &[u8]); // Create parent directory if it doesn't exist if let Some(parent) = key_path.parent() { @@ -163,11 +163,14 @@ impl ConfigEncryption { // Split nonce and ciphertext let (nonce_bytes, ciphertext) = combined.split_at(12); - let nonce = Nonce::from_slice(nonce_bytes); + let nonce_arr: [u8; 12] = nonce_bytes + .try_into() + .expect("12-byte nonce from split_at(12)"); + let nonce = Nonce::from(nonce_arr); let plaintext = self .cipher - .decrypt(nonce, ciphertext) + .decrypt(&nonce, ciphertext) .map_err(|e| ConfigError::ValidationError(format!("Failed to decrypt value: {}", e)))?; String::from_utf8(plaintext).map_err(|e| { diff --git a/crates/foundation/crates/rustelo_server/src/config/tests.rs b/crates/foundation/crates/rustelo_server/src/config/tests.rs index f1c7714..f44a084 100644 --- a/crates/foundation/crates/rustelo_server/src/config/tests.rs +++ b/crates/foundation/crates/rustelo_server/src/config/tests.rs @@ -4,189 +4,37 @@ #[cfg(test)] mod config_tests { - use std::fs; - - use tempfile::tempdir; - use super::super::*; #[test] fn test_config_loading() { - let dir = tempdir().unwrap(); - let config_path = dir.path().join("config.toml"); + // JSON representation mirrors nickel export output — serde_json is the + // actual deserialisation path used by Config::load() after NCL export. + let json = r#"{ + "server": { "protocol": "http", "host": "127.0.0.1", "port": 3030, "environment": "development", "log_level": "info" }, + "database": { "url": "postgresql://test:test@localhost:5432/test_db", "max_connections": 10, "min_connections": 1, "connect_timeout": 30, "idle_timeout": 600, "max_lifetime": 1800, "create_if_missing": false }, + "session": { "secret": "test-secret-key", "cookie_name": "session_id", "cookie_secure": false, "cookie_http_only": true, "cookie_same_site": "lax", "max_age": 3600 }, + "cors": { "allowed_origins": ["http://localhost:3030"], "allowed_methods": ["GET","POST"], "allowed_headers": ["Content-Type"], "allow_credentials": true, "max_age": 3600 }, + "static": { "assets_dir": "public", "site_root": "target/site", "site_pkg_dir": "pkg" }, + "security": { "enable_csrf": true, "csrf_token_name": "csrf_token", "rate_limit_requests": 100, "rate_limit_window": 60, "bcrypt_cost": 12 }, + "oauth": { "enabled": false }, + "email": { "enabled": false, "provider": "console", "smtp_host": "smtp.gmail.com", "smtp_port": 587, "smtp_username": "", "smtp_password": "", "smtp_use_tls": false, "smtp_use_starttls": true, "sendgrid_api_key": "", "sendgrid_endpoint": "https://api.sendgrid.com/v3/mail/send", "from_email": "", "from_name": "Test App" }, + "redis": { "enabled": false, "url": "redis://localhost:6379", "pool_size": 10, "connection_timeout": 5, "command_timeout": 5 }, + "app": { "name": "Test App", "version": "0.1.0", "debug": true, "enable_metrics": false, "enable_health_check": true, "enable_compression": true, "max_request_size": 10485760 }, + "logging": { "format": "json", "level": "info", "file_path": "logs/app.log", "max_file_size": 10485760, "max_files": 5, "enable_console": true, "enable_file": false }, + "cache": { "persistent_enabled": false, "memory_enabled": true, "memory_max_size": 104857600, "memory_ttl": 3600, "persistent_ttl": 86400, "persistent_max_size": 1073741824, "persistent_cleanup_interval": 3600 }, + "server_dirs": { "public_dir": "public", "uploads_dir": "uploads", "logs_dir": "logs", "temp_dir": "tmp", "cache_dir": "cache", "config_dir": "config", "data_dir": "data", "backup_dir": "backups" }, + "features": { + "auth": { "enabled": true, "jwt": true, "oauth": false, "two_factor": false, "sessions": true, "password_reset": true, "email_verification": false }, + "rbac": { "enabled": false, "database_access": false, "file_access": false, "content_access": false, "api_access": false, "categories": false, "tags": false, "caching": false, "audit_logging": false, "toml_config": false, "hierarchical_permissions": false, "dynamic_rules": false }, + "content": { "enabled": true, "markdown": true, "syntax_highlighting": false, "file_uploads": true, "versioning": false, "scheduling": false, "seo": true }, + "security": { "csrf": true, "security_headers": true, "rate_limiting": true, "input_sanitization": true, "sql_injection_protection": true, "xss_protection": true, "content_security_policy": true }, + "performance": { "response_caching": true, "query_caching": true, "compression": true, "connection_pooling": true, "lazy_loading": false, "background_tasks": true }, + "custom": {} + } + }"#; - let config_content = r#" -[server] -protocol = "http" -host = "127.0.0.1" -port = 3030 -environment = "development" -log_level = "info" - -[database] -url = "postgresql://test:test@localhost:5432/test_db" -max_connections = 10 -min_connections = 1 -connect_timeout = 30 -idle_timeout = 600 -max_lifetime = 1800 - -[session] -secret = "test-secret-key" -cookie_name = "session_id" -cookie_secure = false -cookie_http_only = true -cookie_same_site = "lax" -max_age = 3600 - -[cors] -allowed_origins = ["http://localhost:3030"] -allowed_methods = ["GET", "POST"] -allowed_headers = ["Content-Type"] -allow_credentials = true -max_age = 3600 - -[static] -assets_dir = "public" -site_root = "target/site" -site_pkg_dir = "pkg" - -[security] -enable_csrf = true -csrf_token_name = "csrf_token" -rate_limit_requests = 100 -rate_limit_window = 60 -bcrypt_cost = 12 - -[oauth] -enabled = false - -[email] -enabled = false -provider = "console" -smtp_host = "smtp.gmail.com" -smtp_port = 587 -smtp_username = "test@example.com" -smtp_password = "password" -smtp_use_tls = true -smtp_use_starttls = false -sendgrid_api_key = "" -sendgrid_endpoint = "https://api.sendgrid.com/v3/mail/send" -from_email = "noreply@example.com" -from_name = "Test App" -template_dir = "templates/email" - -[redis] -enabled = false -url = "redis://localhost:6379" -pool_size = 10 -connection_timeout = 5 -command_timeout = 5 - -[app] -name = "Test App" -version = "0.1.0" -debug = true -enable_metrics = false -enable_health_check = true -enable_compression = true -max_request_size = 10485760 - -[logging] -format = "json" -level = "info" -file_path = "logs/app.log" -max_file_size = 10485760 -max_files = 5 -enable_console = true -enable_file = false - -[content] -enabled = false -content_dir = "content" -cache_enabled = true -cache_ttl = 3600 -max_file_size = 5242880 - -[cache] -# Enable persistent disk-based caching -persistent_enabled = true -# Memory cache settings -memory_enabled = true -memory_max_size = 104857600 # 100MB -memory_ttl = 3600 # 1 hour -# Persistent cache settings -persistent_ttl = 86400 # 24 hours -persistent_max_size = 1073741824 # 1GB -persistent_cleanup_interval = 3600 # 1 hour - -[features] -[features.auth] -enabled = true -jwt = true -oauth = false -two_factor = false -sessions = true -password_reset = true -email_verification = true -account_lockout = true - -[features.rbac] -enabled = false -database_access = false -file_access = false -content_access = false -api_access = false -categories = false -tags = false -caching = false -audit_logging = false -toml_config = false -hierarchical_permissions = false -dynamic_rules = false - -[features.content] -enabled = true -markdown = true -syntax_highlighting = false -file_uploads = false -versioning = false -scheduling = false -seo = false - -[features.security] -csrf = true -security_headers = true -rate_limiting = true -input_sanitization = true -sql_injection_protection = true -xss_protection = true -content_security_policy = true - -[features.performance] -response_caching = false -query_caching = false -compression = true -connection_pooling = true -lazy_loading = false -background_tasks = false - -[features.custom] - -[server_dirs] -public_dir = "public" -uploads_dir = "uploads" -logs_dir = "logs" -temp_dir = "tmp" -cache_dir = "cache" -config_dir = "config" -data_dir = "data" -backup_dir = "backups" -"#; - - fs::write(&config_path, config_content).expect("Failed to write config file"); - - let config = Config::load_from_file(&config_path).expect("Failed to load config"); + let config: Config = serde_json::from_str(json).expect("Failed to deserialise Config"); assert_eq!(config.server.host, "127.0.0.1"); assert_eq!(config.server.port, 3030); assert_eq!(config.app.name, "Test App"); diff --git a/crates/foundation/crates/rustelo_server/src/content/api.rs b/crates/foundation/crates/rustelo_server/src/content/api.rs index 727c73c..4f6ea90 100644 --- a/crates/foundation/crates/rustelo_server/src/content/api.rs +++ b/crates/foundation/crates/rustelo_server/src/content/api.rs @@ -252,8 +252,9 @@ async fn rebuild_content( Ok(_) => { tracing::info!("Content rebuild triggered successfully via API"); - // Clear filter cache after successful rebuild - rustelo_components::filter::clear_filter_cache(); + // Clear Leptos filter cache if leptos-hydration is active. + #[cfg(feature = "leptos-hydration")] + rustelo_components_leptos::filter::clear_filter_cache(); Ok(Json(ContentApiResponse { success: true, @@ -332,8 +333,8 @@ async fn get_content_status( async fn clear_content_cache( State(_state): State>, ) -> Result, StatusCode> { - // Clear the filter cache - rustelo_components::filter::clear_filter_cache(); + #[cfg(feature = "leptos-hydration")] + rustelo_components_leptos::filter::clear_filter_cache(); tracing::info!("Content cache cleared via API"); diff --git a/crates/foundation/crates/rustelo_server/src/content/mod.rs b/crates/foundation/crates/rustelo_server/src/content/mod.rs index 1d9ee1f..634ff64 100644 --- a/crates/foundation/crates/rustelo_server/src/content/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/content/mod.rs @@ -20,7 +20,7 @@ pub mod service; pub mod version_control; #[cfg(feature = "content-watcher")] pub mod watcher; -#[cfg(any(feature = "content-static", feature = "content-db"))] +// websocket is always available — used for RBAC sync and content notifications pub mod websocket; // Test modules diff --git a/crates/foundation/crates/rustelo_server/src/content/routes.rs b/crates/foundation/crates/rustelo_server/src/content/routes.rs index b8d78d7..40b34bd 100644 --- a/crates/foundation/crates/rustelo_server/src/content/routes.rs +++ b/crates/foundation/crates/rustelo_server/src/content/routes.rs @@ -149,25 +149,25 @@ pub struct CreateContentRequest { pub fn create_content_routes() -> Router> { Router::new() .route("/contents", get(list_contents).post(create_content)) - .route("/contents/:id", get(get_content_by_id)) - .route("/contents/slug/:slug", get(get_content_by_slug)) - .route("/contents/slug/:slug/render", get(render_content_by_slug)) + .route("/contents/{id}", get(get_content_by_id)) + .route("/contents/slug/{slug}", get(get_content_by_slug)) + .route("/contents/slug/{slug}/render", get(render_content_by_slug)) .route("/contents/search", get(search_contents)) .route("/contents/published", get(get_published_contents)) .route("/contents/stats", get(get_content_stats)) .route("/contents/tags", get(get_all_tags)) .route("/contents/categories", get(get_all_categories)) - .route("/contents/type/:content_type", get(get_contents_by_type)) + .route("/contents/type/{content_type}", get(get_contents_by_type)) .route( - "/contents/category/:category", + "/contents/category/{category}", get(get_contents_by_category), ) - .route("/contents/author/:author_id", get(get_contents_by_author)) + .route("/contents/author/{author_id}", get(get_contents_by_author)) .route("/contents/recent", get(get_recent_contents)) .route("/contents/popular", get(get_popular_contents)) - .route("/contents/:id/increment-view", post(increment_view_count)) - .route("/contents/:id/render", get(render_content_by_id)) - .route("/contents/:id/toc", get(get_table_of_contents)) + .route("/contents/{id}/increment-view", post(increment_view_count)) + .route("/contents/{id}/render", get(render_content_by_id)) + .route("/contents/{id}/toc", get(get_table_of_contents)) .route("/contents/reload", post(reload_content)) .route( "/contents/publish-scheduled", diff --git a/crates/foundation/crates/rustelo_server/src/content/service.rs b/crates/foundation/crates/rustelo_server/src/content/service.rs index cf7be52..f7bd008 100644 --- a/crates/foundation/crates/rustelo_server/src/content/service.rs +++ b/crates/foundation/crates/rustelo_server/src/content/service.rs @@ -1499,7 +1499,7 @@ impl ContentService { author: Option, ) -> Result<()> { if let Some(ref version_control) = self.version_control { - if super::version_control::rustelo_utils::should_auto_version(&content.front_matter) { + if super::version_control::utils::should_auto_version(&content.front_matter) { if let Some(version) = version_control .auto_version_content(content_path, content, author) .await? diff --git a/crates/foundation/crates/rustelo_server/src/content/websocket.rs b/crates/foundation/crates/rustelo_server/src/content/websocket.rs index f79e8db..a659145 100644 --- a/crates/foundation/crates/rustelo_server/src/content/websocket.rs +++ b/crates/foundation/crates/rustelo_server/src/content/websocket.rs @@ -2,7 +2,18 @@ //! //! Provides WebSocket connections for real-time notifications about content //! changes, including content creation, updates, publishing status changes, and -//! cache invalidation. +//! cache invalidation. Also drives JWT lifecycle management (proactive +//! refresh, session revocation, cross-device security events) via targeted +//! per-user messages over the same channel. +//! +//! ## select! arm compatibility note +//! +//! `tokio::select!` does not support `#[cfg]` attributes on arms — they cause +//! macro parse errors. All four arms are therefore always compiled. In +//! non-auth builds: +//! - `refresh_timer` sleeps for `u64::MAX/2` seconds → never fires. +//! - `client_tx_keepalive` keeps the mpsc channel open but nothing ever sends → +//! `client_rx.recv()` blocks forever → arm never fires. use std::{ collections::HashMap, @@ -10,6 +21,7 @@ use std::{ atomic::{AtomicUsize, Ordering}, Arc, }, + time::Duration, }; use axum::{ @@ -19,10 +31,17 @@ use axum::{ }, response::Response, }; +#[cfg(feature = "auth")] +use dashmap::DashMap; use futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; -use tokio::sync::{broadcast, RwLock}; +use tokio::sync::{broadcast, mpsc, RwLock}; use tracing::{debug, error, info, warn}; +#[cfg(feature = "auth")] +use uuid::Uuid; + +#[cfg(feature = "auth")] +use crate::auth::jwt::JwtService; /// WebSocket notification types #[derive(Debug, Clone, Serialize, Deserialize)] @@ -80,6 +99,27 @@ pub enum ContentNotification { details: Option, timestamp: String, }, + /// RBAC policy updated — anonymous deny-paths changed (server → WASM sync) + RbacPolicyUpdated { anonymous_deny_paths: Vec }, + /// JWT access token is approaching expiry — WASM should call + /// server_refresh_token() + #[cfg(feature = "auth")] + TokenRefreshRequested, + /// Session was explicitly revoked (logout-all or admin action) + #[cfg(feature = "auth")] + SessionRevoked { reason: String }, + /// A new login occurred for the same user account on another device + #[cfg(feature = "auth")] + NewDeviceLogin { session_id: String }, + /// A user account field was updated on another device + #[cfg(feature = "auth")] + AccountUpdated { field: String }, + /// Security alert — e.g. brute-force OTP attempts detected + #[cfg(feature = "auth")] + SecurityAlert { message: String }, + /// Push a toast notification to all connected clients. + /// `kind` is one of "info" | "warning" | "error". + ToastBroadcast { kind: String, message: String }, } /// WebSocket client information @@ -89,7 +129,7 @@ pub struct WebSocketClient { pub user_agent: Option, pub ip_address: Option, pub connected_at: chrono::DateTime, - pub subscriptions: Vec, // Content types or topics the client is subscribed to + pub subscriptions: Vec, } /// WebSocket server state @@ -102,18 +142,26 @@ pub struct WebSocketServer { pub client_counter: AtomicUsize, /// Configuration pub config: WebSocketConfig, + /// Current RBAC deny paths for initial sync on new connections. + pub current_deny_paths: Option>>>, + /// Per-client targeted message senders — keyed by client_id. + /// Allows `notify_user` to push directly to individual connections. + #[cfg(feature = "auth")] + pub client_senders: Arc>>, + /// Map from user UUID to connected client IDs for that user. + #[cfg(feature = "auth")] + pub user_connections: Arc>>, + /// JWT service used to decode cookies at WS upgrade time. + #[cfg(feature = "auth")] + pub jwt_service: Option>, } /// WebSocket server configuration #[derive(Debug, Clone)] pub struct WebSocketConfig { - /// Maximum number of concurrent connections pub max_connections: usize, - /// Send heartbeat pings every N seconds pub heartbeat_interval: u64, - /// Close connection if no pong received within N seconds pub ping_timeout: u64, - /// Buffer size for broadcast channel pub broadcast_buffer_size: usize, } @@ -129,7 +177,6 @@ impl Default for WebSocketConfig { } impl WebSocketServer { - /// Create a new WebSocket server pub fn new(config: WebSocketConfig) -> Self { let (broadcast_tx, _) = broadcast::channel(config.broadcast_buffer_size); @@ -138,22 +185,82 @@ impl WebSocketServer { clients: Arc::new(RwLock::new(HashMap::new())), client_counter: AtomicUsize::new(0), config, + current_deny_paths: None, + #[cfg(feature = "auth")] + client_senders: Arc::new(DashMap::new()), + #[cfg(feature = "auth")] + user_connections: Arc::new(DashMap::new()), + #[cfg(feature = "auth")] + jwt_service: None, } } - /// Handle WebSocket upgrade request + pub fn with_deny_paths(config: WebSocketConfig, deny_paths: Arc>>) -> Self { + let mut server = Self::new(config); + server.current_deny_paths = Some(deny_paths); + server + } + + /// Attach a JWT service so `handle_socket` can decode the session cookie + /// at upgrade time and register user→connection mappings. + #[cfg(feature = "auth")] + pub fn with_jwt_service(mut self, jwt_svc: Arc) -> Self { + self.jwt_service = Some(jwt_svc); + self + } + + /// Send a targeted notification to all active connections for `user_id`. + #[cfg(feature = "auth")] + pub async fn notify_user(&self, user_id: Uuid, notification: ContentNotification) { + let client_ids: Vec = self + .user_connections + .get(&user_id) + .map(|v| v.clone()) + .unwrap_or_default(); + + for cid in client_ids { + if let Some(tx) = self.client_senders.get(&cid) { + let _ = tx.send(notification.clone()).await; + } + } + } + + /// Handle WebSocket upgrade request — extracts cookies before the upgrade + /// so they can be used inside `handle_socket` for JWT decoding. pub async fn handle_upgrade( ws: WebSocketUpgrade, State(server): State>, + headers: axum::http::HeaderMap, ) -> Response { - ws.on_upgrade(move |socket| server.handle_socket(socket)) + ws.on_upgrade(move |socket| server.handle_socket(socket, headers)) } - /// Handle a WebSocket connection - pub async fn handle_socket(self: Arc, socket: WebSocket) { + /// Handle a WebSocket connection. + /// + /// Uses a four-arm select! loop. Arms 3 & 4 (refresh timer and targeted + /// notifications) are inert in non-auth builds: the timer sleeps for + /// `u64::MAX/2` seconds and the mpsc channel has no active senders. + pub async fn handle_socket(self: Arc, socket: WebSocket, headers: axum::http::HeaderMap) { + // Suppress unused-variable warning in non-auth builds where headers are + // only read by the auth-gated JWT extraction below. + #[cfg(not(feature = "auth"))] + let _ = &headers; + let client_id = self.client_counter.fetch_add(1, Ordering::SeqCst); - // Check connection limit + // Compute initial RBAC policy snapshot before the connection task. + let initial_policy_json: Option = + if let Some(ref deny_paths_lock) = self.current_deny_paths { + let paths = deny_paths_lock.read().await.clone(); + if !paths.is_empty() { + serde_json::to_string(&ContentNotification::rbac_policy_updated(paths)).ok() + } else { + None + } + } else { + None + }; + { let clients = self.clients.read().await; if clients.len() >= self.config.max_connections { @@ -167,31 +274,83 @@ impl WebSocketServer { info!("WebSocket client {} connected", client_id); - // Create client record - let client = WebSocketClient { - id: client_id, - user_agent: None, // Could be extracted from headers - ip_address: None, // Could be extracted from connection - connected_at: chrono::Utc::now(), - subscriptions: vec!["all".to_string()], // Default to all notifications - }; + // ---- Always-present per-client channel ---- + // In auth builds: sender clone is registered in client_senders so notify_user() + // can push targeted messages; the original sender is moved into the task as + // a keepalive so recv() never returns None while the loop runs. + // In non-auth builds: no registration happens; the original sender is moved + // into the task keeping the channel open but idle → recv() blocks + // forever → arm 4 never fires. + let (client_tx, mut client_rx) = mpsc::channel::(8); - // Add client to active connections - { - let mut clients = self.clients.write().await; - clients.insert(client_id, client); + #[cfg(feature = "auth")] + self.client_senders.insert(client_id, client_tx.clone()); + + // ---- Auth: JWT decode + user→connection mapping ---- + #[cfg(feature = "auth")] + let authenticated_user_id: Option = + extract_user_id_from_upgrade_headers(&headers, &self.jwt_service); + + #[cfg(feature = "auth")] + if let Some(uid) = authenticated_user_id { + self.user_connections + .entry(uid) + .or_insert_with(Vec::new) + .push(client_id); + } + + // ---- Refresh timer ---- + // Auth builds: fires 120 s before JWT expiry. + // Non-auth builds: sleeps for u64::MAX/2 seconds — effectively never fires. + let refresh_sleep = { + #[cfg(feature = "auth")] + { + compute_refresh_sleep(&self.jwt_service, &headers) + } + #[cfg(not(feature = "auth"))] + { + Duration::from_secs(u64::MAX / 2) + } + }; + let mut refresh_timer = Box::pin(tokio::time::sleep(refresh_sleep)); + + // Register client + { + let mut clients = self.clients.write().await; + clients.insert( + client_id, + WebSocketClient { + id: client_id, + user_agent: None, + ip_address: None, + connected_at: chrono::Utc::now(), + subscriptions: vec!["all".to_string()], + }, + ); } - // Handle WebSocket connection in a single task let mut broadcast_rx = self.broadcast_tx.subscribe(); let server_clone = self.clone(); let connection_task = tokio::spawn(async move { + // Keep the original sender alive for the duration of the task. + // - Auth builds: prevents the channel from closing when client_senders is + // cleaned up before the task ends. + // - Non-auth builds: the only sender, so this is what keeps the channel open. + let _tx_keepalive = client_tx; + let (mut sender, mut receiver) = socket.split(); + if let Some(json) = initial_policy_json { + if sender.send(Message::Text(json.into())).await.is_err() { + debug!("Failed to send initial policy to client {}", client_id); + return; + } + } + loop { tokio::select! { - // Handle outgoing messages (broadcast to client) + // Arm 1 — broadcast to all connected clients broadcast_result = broadcast_rx.recv() => { match broadcast_result { Ok(notification) => { @@ -202,9 +361,8 @@ impl WebSocketServer { continue; } }; - if sender.send(message).await.is_err() { - debug!("Failed to send message to client {}, connection closed", client_id); + debug!("Failed to send message to client {}, closing", client_id); break; } } @@ -215,7 +373,7 @@ impl WebSocketServer { } } - // Handle incoming messages from client + // Arm 2 — incoming client → server messages msg_result = receiver.next() => { match msg_result { Some(Ok(Message::Text(text))) => { @@ -249,14 +407,51 @@ impl WebSocketServer { } } } + + // Arm 3 — proactive JWT refresh notification + // Non-auth builds: refresh_timer sleeps forever, this arm never fires. + _ = &mut refresh_timer => { + #[cfg(feature = "auth")] + { + let json = serde_json::to_string(&ContentNotification::TokenRefreshRequested) + .unwrap_or_default(); + let _ = sender.send(Message::Text(json.into())).await; + } + // Park the timer after firing — reconnection re-arms it. + refresh_timer = Box::pin(tokio::time::sleep(Duration::from_secs(u64::MAX / 2))); + } + + // Arm 4 — targeted per-user notifications (session revocation, etc.) + // Non-auth builds: _tx_keepalive is the only sender and never sends → + // this arm blocks indefinitely and never fires. + Some(notif) = client_rx.recv() => { + match serde_json::to_string(¬if) { + Ok(json) => { + if sender.send(Message::Text(json.into())).await.is_err() { + debug!("Failed to send targeted notification to client {}, closing", client_id); + break; + } + } + Err(e) => error!("Failed to serialize targeted notification: {}", e), + } + } } } }); - // Wait for connection task to complete let _ = connection_task.await; - // Remove client from active connections + // ---- Cleanup ---- + #[cfg(feature = "auth")] + { + self.client_senders.remove(&client_id); + if let Some(uid) = authenticated_user_id { + if let Some(mut entry) = self.user_connections.get_mut(&uid) { + entry.retain(|&id| id != client_id); + } + } + } + { let mut clients = self.clients.write().await; clients.remove(&client_id); @@ -265,11 +460,9 @@ impl WebSocketServer { info!("WebSocket client {} disconnected", client_id); } - /// Handle incoming message from client async fn handle_client_message(&self, client_id: usize, message: String) -> anyhow::Result<()> { debug!("Received message from client {}: {}", client_id, message); - // Parse client message let client_msg: ClientMessage = serde_json::from_str(&message)?; match client_msg { @@ -296,75 +489,136 @@ impl WebSocketServer { } } ClientMessage::Ping => { - // Send back a system status as pong let notification = ContentNotification::SystemStatus { status: "healthy".to_string(), message: "Pong".to_string(), timestamp: chrono::Utc::now().to_rfc3339(), }; - if let Ok(json) = serde_json::to_string(¬ification) { - // Send directly to this client (not broadcast) - // For now, we'll just log it since we'd need access to the sender debug!("Would send pong to client {}: {}", client_id, json); } } + ClientMessage::RouteView { path, language } => { + debug!( + "Client {} navigated to '{}' (language: '{}')", + client_id, path, language + ); + } } Ok(()) } - /// Broadcast notification to all connected clients pub async fn broadcast_notification(&self, notification: ContentNotification) { debug!("Broadcasting notification: {:?}", notification); - if let Err(e) = self.broadcast_tx.send(notification) { error!("Failed to broadcast notification: {}", e); } } - /// Send notification to specific topics/subscriptions pub async fn send_to_subscribers(&self, notification: ContentNotification, topics: &[String]) { debug!( "Sending notification to topics {:?}: {:?}", topics, notification ); - - // For now, just broadcast to all (topic filtering could be implemented later) if let Err(e) = self.broadcast_tx.send(notification) { error!("Failed to send notification to subscribers: {}", e); } } - /// Get current connection count pub async fn connection_count(&self) -> usize { let clients = self.clients.read().await; clients.len() } - /// Get connected clients info pub async fn get_clients_info(&self) -> Vec { let clients = self.clients.read().await; clients.values().cloned().collect() } } -/// Messages that clients can send to the server +// --------------------------------------------------------------------------- +// Auth-gated helper functions +// --------------------------------------------------------------------------- + +/// Extract the authenticated user UUID from the HTTP Upgrade request's cookies. +/// Returns `None` if no JWT service is configured, no session cookie is +/// present, or the token fails validation. +#[cfg(feature = "auth")] +fn extract_user_id_from_upgrade_headers( + headers: &axum::http::HeaderMap, + jwt_service: &Option>, +) -> Option { + let svc = jwt_service.as_ref()?; + let cookie_hdr = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; + + let token = cookie_hdr + .split(';') + .find_map(|pair| pair.trim().strip_prefix("session_token="))? + .to_string(); + + let claims = svc.verify_access_token(&token).ok()?; + claims.sub.parse::().ok() +} + +/// Compute how long to sleep before sending `TokenRefreshRequested`. +/// Targets `exp − 120 s`; returns 1 s when the token is already near expiry. +/// Returns `u64::MAX / 2` when no JWT service is configured or no token cookie +/// exists. +#[cfg(feature = "auth")] +fn compute_refresh_sleep( + jwt_service: &Option>, + headers: &axum::http::HeaderMap, +) -> Duration { + let Some(svc) = jwt_service.as_ref() else { + return Duration::from_secs(u64::MAX / 2); + }; + + let cookie_hdr = headers + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let token = cookie_hdr + .split(';') + .find_map(|pair| pair.trim().strip_prefix("session_token=")) + .unwrap_or(""); + + let Ok(claims) = svc.verify_access_token(token) else { + return Duration::from_secs(u64::MAX / 2); + }; + + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let exp = claims.exp as u64; + if exp <= now_secs + 120 { + Duration::from_secs(1) + } else { + Duration::from_secs(exp - now_secs - 120) + } +} + +// --------------------------------------------------------------------------- +// Client → Server messages +// --------------------------------------------------------------------------- + #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum ClientMessage { - /// Subscribe to specific topics Subscribe { topics: Vec }, - /// Unsubscribe from topics Unsubscribe { topics: Vec }, - /// Ping message Ping, + RouteView { path: String, language: String }, } -/// Helper functions for creating common notifications +// --------------------------------------------------------------------------- +// ContentNotification constructor helpers +// --------------------------------------------------------------------------- impl ContentNotification { - /// Create a content created notification pub fn content_created( id: String, title: String, @@ -381,7 +635,6 @@ impl ContentNotification { } } - /// Create a content updated notification pub fn content_updated( id: String, title: String, @@ -396,7 +649,6 @@ impl ContentNotification { } } - /// Create a content published notification pub fn content_published( id: String, title: String, @@ -411,7 +663,6 @@ impl ContentNotification { } } - /// Create a cache invalidated notification pub fn cache_invalidated( cache_types: Vec, affected_languages: Vec, @@ -424,7 +675,6 @@ impl ContentNotification { } } - /// Create a system status notification pub fn system_status(status: String, message: String) -> Self { Self::SystemStatus { status, @@ -433,7 +683,6 @@ impl ContentNotification { } } - /// Create an error notification pub fn error(message: String, details: Option) -> Self { Self::Error { message, @@ -441,8 +690,18 @@ impl ContentNotification { timestamp: chrono::Utc::now().to_rfc3339(), } } + + pub fn rbac_policy_updated(anonymous_deny_paths: Vec) -> Self { + Self::RbacPolicyUpdated { + anonymous_deny_paths, + } + } } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -489,7 +748,23 @@ mod tests { let notification = ContentNotification::system_status("test".to_string(), "Test message".to_string()); - // Should not panic even with no connected clients server.broadcast_notification(notification).await; } + + #[cfg(feature = "auth")] + #[test] + fn test_auth_notification_serialization() { + let notif = ContentNotification::SessionRevoked { + reason: "logout_all".to_string(), + }; + let json = serde_json::to_string(¬if).unwrap(); + assert!(json.contains("SessionRevoked")); + assert!(json.contains("logout_all")); + + let notif2 = ContentNotification::SecurityAlert { + message: "Too many OTP attempts".to_string(), + }; + let json2 = serde_json::to_string(¬if2).unwrap(); + assert!(json2.contains("SecurityAlert")); + } } diff --git a/crates/foundation/crates/rustelo_server/src/content_loader.rs b/crates/foundation/crates/rustelo_server/src/content_loader.rs index 6b1084f..23a043f 100644 --- a/crates/foundation/crates/rustelo_server/src/content_loader.rs +++ b/crates/foundation/crates/rustelo_server/src/content_loader.rs @@ -224,20 +224,7 @@ impl ContentLoader { } Ok(ContentManifest { - generated_at: { - #[cfg(feature = "chrono")] - { - chrono::Utc::now().to_rfc3339() - } - #[cfg(not(feature = "chrono"))] - { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - .to_string() - } - }, + generated_at: chrono::Utc::now().to_rfc3339(), languages: self.supported_languages.clone(), content_types, structure: ContentStructure { diff --git a/crates/foundation/crates/rustelo_server/src/crypto/config.rs b/crates/foundation/crates/rustelo_server/src/crypto/config.rs index 2803aaf..781d0f4 100644 --- a/crates/foundation/crates/rustelo_server/src/crypto/config.rs +++ b/crates/foundation/crates/rustelo_server/src/crypto/config.rs @@ -552,11 +552,11 @@ mod tests { #[test] fn test_mask_for_logging() { - assert_eq!(rustelo_utils::mask_for_logging("short", 2), "sh*rt"); + assert_eq!(super::utils::mask_for_logging("short", 2), "sh*rt"); assert_eq!( - rustelo_utils::mask_for_logging("postgresql://user:password@localhost/db", 4), + super::utils::mask_for_logging("postgresql://user:password@localhost/db", 4), "post*******************************t/db" ); - assert_eq!(rustelo_utils::mask_for_logging("secret123", 2), "se*****23"); + assert_eq!(super::utils::mask_for_logging("secret123", 2), "se*****23"); } } diff --git a/crates/foundation/crates/rustelo_server/src/crypto/integration.rs b/crates/foundation/crates/rustelo_server/src/crypto/integration.rs index bdbc88e..7067d3d 100644 --- a/crates/foundation/crates/rustelo_server/src/crypto/integration.rs +++ b/crates/foundation/crates/rustelo_server/src/crypto/integration.rs @@ -71,14 +71,14 @@ impl AppStateWithCrypto { /// Get database URL from encrypted config pub fn get_database_url(&self) -> Result> { - Ok(crate::crypto::config::rustelo_utils::get_database_url( + Ok(crate::crypto::config::utils::get_database_url( &self.config_store, )?) } /// Get JWT secret from encrypted config pub fn get_jwt_secret(&self) -> Result> { - Ok(crate::crypto::config::rustelo_utils::get_jwt_secret( + Ok(crate::crypto::config::utils::get_jwt_secret( &self.config_store, )?) } diff --git a/crates/foundation/crates/rustelo_server/src/crypto/mod.rs b/crates/foundation/crates/rustelo_server/src/crypto/mod.rs index 1eee6b7..e9cf2d0 100644 --- a/crates/foundation/crates/rustelo_server/src/crypto/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/crypto/mod.rs @@ -74,10 +74,11 @@ impl CryptoService { )); } - let key = Key::::from_slice(key_bytes); - let cipher = Aes256Gcm::new(key); + let key_arr: [u8; 32] = key_bytes.try_into().expect("validated 32 bytes"); + let key = Key::::from(key_arr); + let cipher = Aes256Gcm::new(&key); - Ok(Self { cipher, key: *key }) + Ok(Self { cipher, key }) } /// Get encryption key from environment or generate a new one @@ -87,7 +88,8 @@ impl CryptoService { match general_purpose::STANDARD.decode(&key_base64) { Ok(key_bytes) => { if key_bytes.len() == 32 { - return Ok(*Key::::from_slice(&key_bytes)); + let arr: [u8; 32] = key_bytes.as_slice().try_into().expect("checked 32"); + return Ok(Key::::from(arr)); } } Err(_) => { @@ -126,11 +128,16 @@ impl CryptoService { /// Decrypt bytes from encrypted data pub fn decrypt_bytes(&self, encrypted_data: &EncryptedData) -> Result, CryptoError> { - let nonce = Nonce::from_slice(&encrypted_data.nonce); + let nonce_arr: [u8; 12] = encrypted_data + .nonce + .as_slice() + .try_into() + .map_err(|_| CryptoError::DecryptionError("nonce must be 12 bytes".to_string()))?; + let nonce = Nonce::from(nonce_arr); match self .cipher - .decrypt(nonce, encrypted_data.ciphertext.as_ref()) + .decrypt(&nonce, encrypted_data.ciphertext.as_ref()) { Ok(plaintext) => Ok(plaintext), Err(e) => Err(CryptoError::DecryptionError(format!( @@ -434,9 +441,9 @@ mod tests { let crypto = CryptoService::new().expect("Failed to create crypto service"); let user = create_test_user(); - let encrypted_session = rustelo_utils::encrypt_user_session(&crypto, &user, 3600).unwrap(); + let encrypted_session = super::utils::encrypt_user_session(&crypto, &user, 3600).unwrap(); let decrypted_session = - rustelo_utils::decrypt_user_session(&crypto, &encrypted_session).unwrap(); + super::utils::decrypt_user_session(&crypto, &encrypted_session).unwrap(); assert_eq!(user.id.to_string(), decrypted_session.user_id); assert_eq!(user.email, decrypted_session.email); @@ -456,13 +463,13 @@ mod tests { #[test] fn test_mask_sensitive_data() { - assert_eq!(rustelo_utils::mask_sensitive_data("short"), "*****"); + assert_eq!(super::utils::mask_sensitive_data("short"), "*****"); assert_eq!( - rustelo_utils::mask_sensitive_data("postgresql://user:password@localhost/db"), + super::utils::mask_sensitive_data("postgresql://user:password@localhost/db"), "post****t/db" ); assert_eq!( - rustelo_utils::mask_sensitive_data("secret123"), + super::utils::mask_sensitive_data("secret123"), "secr****t123" ); } @@ -497,8 +504,8 @@ mod tests { let user = create_test_user(); // Create expired session (expires in -1 seconds) - let encrypted_session = rustelo_utils::encrypt_user_session(&crypto, &user, -1).unwrap(); - let result = rustelo_utils::decrypt_user_session(&crypto, &encrypted_session); + let encrypted_session = super::utils::encrypt_user_session(&crypto, &user, -1).unwrap(); + let result = super::utils::decrypt_user_session(&crypto, &encrypted_session); assert!(result.is_err()); } diff --git a/crates/foundation/crates/rustelo_server/src/crypto/session.rs b/crates/foundation/crates/rustelo_server/src/crypto/session.rs index 59c0ae6..2ea639c 100644 --- a/crates/foundation/crates/rustelo_server/src/crypto/session.rs +++ b/crates/foundation/crates/rustelo_server/src/crypto/session.rs @@ -80,7 +80,7 @@ impl EncryptedSessionStore { /// Create an encrypted session for a user pub fn create_session(&self, user: &User) -> Result { let session_data = - crate::crypto::rustelo_utils::user_to_session_data(user, self.config.session_lifetime); + crate::crypto::utils::user_to_session_data(user, self.config.session_lifetime); self.crypto.encrypt_json(&session_data) } @@ -89,7 +89,7 @@ impl EncryptedSessionStore { &self, encrypted_session: &str, ) -> Result { - crate::crypto::rustelo_utils::decrypt_user_session(&self.crypto, encrypted_session) + crate::crypto::utils::decrypt_user_session(&self.crypto, encrypted_session) } /// Set session cookie diff --git a/crates/foundation/crates/rustelo_server/src/database/activity.rs b/crates/foundation/crates/rustelo_server/src/database/activity.rs new file mode 100644 index 0000000..0bc08fc --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/database/activity.rs @@ -0,0 +1,223 @@ +//! Activity completions repository +//! +//! Tracks which users have completed questionnaires for activities (talks, +//! workshops, presentations) and stores their responses for internal +//! questionnaires. Methods are added to [`AuthRepository`] so the OTP +//! adapter can reach them through `svc.auth_repo` without an extra Arc. + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::database::{auth::AuthRepository, connection::DatabaseParam, DatabaseType}; + +// ── Row types ──────────────────────────────────────────────────────────────── + +/// A row from the `activity_completions` table. +#[derive(Debug, Clone)] +pub struct ActivityCompletionRow { + pub id: Uuid, + pub user_id: Uuid, + pub activity_id: String, + pub completed_at: DateTime, + pub questionnaire_type: String, + pub notes: Option, +} + +/// A row from the `activity_responses` table. +#[derive(Debug, Clone)] +pub struct ActivityResponseRow { + pub id: Uuid, + pub completion_id: Uuid, + pub question_key: String, + pub answer: String, +} + +// ── AuthRepository extension ───────────────────────────────────────────────── + +impl AuthRepository { + /// Return `true` if the user has already completed the questionnaire for + /// the given activity. + pub async fn check_activity_completion( + &self, + user_id: Uuid, + activity_id: &str, + ) -> Result { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT COUNT(*) AS cnt FROM activity_completions WHERE user_id = $1 AND \ + activity_id = $2" + } + DatabaseType::SQLite => { + "SELECT COUNT(*) AS cnt FROM activity_completions WHERE user_id = ?1 AND \ + activity_id = ?2" + } + }; + let rows = self + .database + .fetch_all( + query, + &[ + DatabaseParam::Uuid(user_id), + DatabaseParam::String(activity_id.to_string()), + ], + ) + .await?; + let count: i64 = rows + .first() + .and_then(|r| r.get_i64("cnt").ok()) + .unwrap_or(0); + Ok(count > 0) + } + + /// Record that `user_id` completed the questionnaire for `activity_id`. + /// + /// Silently succeeds if the completion already exists (UNIQUE constraint + /// on `(user_id, activity_id)`). + pub async fn record_activity_completion( + &self, + user_id: Uuid, + activity_id: &str, + questionnaire_type: &str, + ) -> Result { + let completion_id = Uuid::new_v4(); + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO activity_completions (id, user_id, activity_id, questionnaire_type) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (user_id, activity_id) DO NOTHING RETURNING id" + } + DatabaseType::SQLite => { + "INSERT OR IGNORE INTO activity_completions (id, user_id, activity_id, \ + questionnaire_type) VALUES (?1, ?2, ?3, ?4)" + } + }; + + match self.database.database_type() { + DatabaseType::PostgreSQL => { + let rows = self + .database + .fetch_all( + query, + &[ + DatabaseParam::Uuid(completion_id), + DatabaseParam::Uuid(user_id), + DatabaseParam::String(activity_id.to_string()), + DatabaseParam::String(questionnaire_type.to_string()), + ], + ) + .await?; + // If a row was already there, RETURNING returns nothing — we look it up. + if let Some(row) = rows.first() { + return Ok(row.get_uuid("id")?); + } + self.find_completion_id(user_id, activity_id).await + } + DatabaseType::SQLite => { + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(completion_id), + DatabaseParam::Uuid(user_id), + DatabaseParam::String(activity_id.to_string()), + DatabaseParam::String(questionnaire_type.to_string()), + ], + ) + .await?; + self.find_completion_id(user_id, activity_id).await + } + } + } + + /// Look up the completion UUID for an (user, activity) pair. + async fn find_completion_id(&self, user_id: Uuid, activity_id: &str) -> Result { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT id FROM activity_completions WHERE user_id = $1 AND activity_id = $2" + } + DatabaseType::SQLite => { + "SELECT id FROM activity_completions WHERE user_id = ?1 AND activity_id = ?2" + } + }; + let rows = self + .database + .fetch_all( + query, + &[ + DatabaseParam::Uuid(user_id), + DatabaseParam::String(activity_id.to_string()), + ], + ) + .await?; + rows.first() + .ok_or_else(|| anyhow::anyhow!("completion not found after insert"))? + .get_uuid("id") + .map_err(Into::into) + } + + /// Store answers for an internal questionnaire completion. + /// + /// Each `(question_key, answer)` pair is inserted; duplicates are ignored + /// so this is idempotent. + pub async fn store_activity_responses( + &self, + completion_id: Uuid, + responses: &[(String, String)], + ) -> Result<()> { + for (question_key, answer) in responses { + let response_id = Uuid::new_v4(); + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO activity_responses (id, completion_id, question_key, answer) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (completion_id, question_key) DO NOTHING" + } + DatabaseType::SQLite => { + "INSERT OR IGNORE INTO activity_responses (id, completion_id, question_key, \ + answer) VALUES (?1, ?2, ?3, ?4)" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(response_id), + DatabaseParam::Uuid(completion_id), + DatabaseParam::String(question_key.clone()), + DatabaseParam::String(answer.clone()), + ], + ) + .await?; + } + Ok(()) + } + + /// Return all completions for a user, newest first. + pub async fn get_user_completions(&self, user_id: Uuid) -> Result> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT id, user_id, activity_id, completed_at, questionnaire_type, notes FROM \ + activity_completions WHERE user_id = $1 ORDER BY completed_at DESC" + } + DatabaseType::SQLite => { + "SELECT id, user_id, activity_id, completed_at, questionnaire_type, notes FROM \ + activity_completions WHERE user_id = ?1 ORDER BY completed_at DESC" + } + }; + let rows = self + .database + .fetch_all(query, &[DatabaseParam::Uuid(user_id)]) + .await?; + rows.iter() + .map(|r| { + Ok(ActivityCompletionRow { + id: r.get_uuid("id")?, + user_id: r.get_uuid("user_id")?, + activity_id: r.get_string("activity_id")?, + completed_at: r.get_datetime("completed_at")?, + questionnaire_type: r.get_string("questionnaire_type")?, + notes: r.get_optional_string("notes")?, + }) + }) + .collect() + } +} diff --git a/crates/foundation/crates/rustelo_server/src/database/auth.rs b/crates/foundation/crates/rustelo_server/src/database/auth.rs index 0512758..aaabbdc 100644 --- a/crates/foundation/crates/rustelo_server/src/database/auth.rs +++ b/crates/foundation/crates/rustelo_server/src/database/auth.rs @@ -5,7 +5,7 @@ use anyhow::Result; use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -213,13 +213,24 @@ pub trait AuthRepositoryTrait: Send + Sync + Clone + 'static { /// Database-agnostic authentication repository implementation #[derive(Debug, Clone)] pub struct AuthRepository { - database: DatabaseConnection, + pub(in crate::database) database: DatabaseConnection, + registration_role: String, } impl AuthRepository { /// Create a new auth repository with a database connection pub fn new(database: DatabaseConnection) -> Self { - Self { database } + Self { + database, + registration_role: "user".to_string(), + } + } + + /// Override the role assigned to newly registered users (default: "user"). + /// The value must match a role name present in site/config/roles.ncl. + pub fn with_registration_role(mut self, role: String) -> Self { + self.registration_role = role; + self } /// Get the database type @@ -237,6 +248,781 @@ impl AuthRepository { pub fn pool(&self) -> &DatabaseConnection { &self.database } + + /// Find a user by email or create a new passwordless account if none + /// exists. + /// + /// Used by the OTP/magic-link flow. A newly created user has an empty + /// `password_hash` (consistent with OAuth users) and is marked as verified + /// because code delivery implies email ownership. + pub async fn find_or_create_by_email(&self, email: &str) -> Result { + if let Some(user) = self.find_user_by_email(email).await? { + return Ok(user); + } + let req = CreateUserRequest { + email: email.to_string(), + username: None, + password_hash: String::new(), + display_name: None, + is_verified: true, + is_active: true, + }; + self.create_user(&req).await + } + + /// Record the GDPR consent timestamp for a newly created account. + pub async fn update_gdpr_accepted_at(&self, user_id: Uuid) -> Result<()> { + let now = Utc::now(); + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "UPDATE users SET gdpr_accepted_at = $1, updated_at = $2 WHERE id = $3" + } + DatabaseType::SQLite => { + "UPDATE users SET gdpr_accepted_at = ?1, updated_at = ?2 WHERE id = ?3" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::DateTime(now), + DatabaseParam::DateTime(now), + DatabaseParam::Uuid(user_id), + ], + ) + .await?; + Ok(()) + } + + /// Retrieve all bookmarks for a user. + pub async fn get_bookmarks(&self, user_id: Uuid) -> Result> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT id, user_id, content_type, content_id, content_title, content_url, \ + created_at FROM user_bookmarks WHERE user_id = $1 ORDER BY created_at DESC" + } + DatabaseType::SQLite => { + "SELECT id, user_id, content_type, content_id, content_title, content_url, \ + created_at FROM user_bookmarks WHERE user_id = ?1 ORDER BY created_at DESC" + } + }; + let rows = self + .database + .fetch_all(query, &[DatabaseParam::Uuid(user_id)]) + .await?; + + rows.iter() + .map(|r| { + Ok(BookmarkRow { + id: r.get_uuid("id")?, + user_id: r.get_uuid("user_id")?, + content_type: r.get_string("content_type")?, + content_id: r.get_string("content_id")?, + content_title: r.get_optional_string("content_title")?, + content_url: r.get_optional_string("content_url")?, + created_at: r.get_datetime("created_at")?, + }) + }) + .collect() + } + + /// Add a bookmark; silently ignores duplicate constraint violations. + pub async fn add_bookmark(&self, req: &AddBookmarkRequest) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now(); + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO user_bookmarks (id, user_id, content_type, content_id, content_title, \ + content_url, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT \ + (user_id, content_type, content_id) DO NOTHING RETURNING id" + } + DatabaseType::SQLite => { + "INSERT OR IGNORE INTO user_bookmarks (id, user_id, content_type, content_id, \ + content_title, content_url, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(id), + DatabaseParam::Uuid(req.user_id), + DatabaseParam::String(req.content_type.clone()), + DatabaseParam::String(req.content_id.clone()), + DatabaseParam::OptionalString(req.content_title.clone()), + DatabaseParam::OptionalString(req.content_url.clone()), + DatabaseParam::DateTime(now), + ], + ) + .await?; + Ok(id) + } + + /// Remove a specific bookmark by its ID, scoped to the owning user. + pub async fn remove_bookmark(&self, bookmark_id: Uuid, user_id: Uuid) -> Result<()> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => "DELETE FROM user_bookmarks WHERE id = $1 AND user_id = $2", + DatabaseType::SQLite => "DELETE FROM user_bookmarks WHERE id = ?1 AND user_id = ?2", + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(bookmark_id), + DatabaseParam::Uuid(user_id), + ], + ) + .await?; + Ok(()) + } + + /// Check whether a specific content item is bookmarked by the user. + pub async fn is_bookmarked( + &self, + user_id: Uuid, + content_id: &str, + content_type: &str, + ) -> Result { + let (query, params): (&str, Vec) = match self.database.database_type() { + DatabaseType::PostgreSQL => ( + "SELECT 1 FROM user_bookmarks WHERE user_id = $1 AND content_id = $2 AND \ + content_type = $3 LIMIT 1", + vec![ + DatabaseParam::Uuid(user_id), + DatabaseParam::String(content_id.to_string()), + DatabaseParam::String(content_type.to_string()), + ], + ), + DatabaseType::SQLite => ( + "SELECT 1 FROM user_bookmarks WHERE user_id = ?1 AND content_id = ?2 AND \ + content_type = ?3 LIMIT 1", + vec![ + DatabaseParam::Uuid(user_id), + DatabaseParam::String(content_id.to_string()), + DatabaseParam::String(content_type.to_string()), + ], + ), + }; + let row = self.database.fetch_optional(query, ¶ms).await?; + Ok(row.is_some()) + } + + /// Update the `display_name` for a user row. + pub async fn update_display_name(&self, user_id: Uuid, name: String) -> Result<()> { + let (query, params): (&str, Vec) = match self.database.database_type() { + DatabaseType::PostgreSQL => ( + "UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2", + vec![DatabaseParam::String(name), DatabaseParam::Uuid(user_id)], + ), + DatabaseType::SQLite => ( + "UPDATE users SET display_name = ?1, updated_at = datetime('now') WHERE id = ?2", + vec![DatabaseParam::String(name), DatabaseParam::Uuid(user_id)], + ), + }; + self.database.execute(query, ¶ms).await?; + Ok(()) + } + + /// Delete a user account: remove all sessions then delete the user row. + pub async fn delete_user_account(&self, user_id: Uuid) -> Result<()> { + let (q_sessions, q_user) = match self.database.database_type() { + DatabaseType::PostgreSQL => ( + "DELETE FROM user_sessions WHERE user_id = $1", + "DELETE FROM users WHERE id = $1", + ), + DatabaseType::SQLite => ( + "DELETE FROM user_sessions WHERE user_id = ?1", + "DELETE FROM users WHERE id = ?1", + ), + }; + self.database + .execute(q_sessions, &[DatabaseParam::Uuid(user_id)]) + .await?; + self.database + .execute(q_user, &[DatabaseParam::Uuid(user_id)]) + .await?; + Ok(()) + } + + /// Retrieve non-deleted messages for a user, newest first. + pub async fn get_messages(&self, user_id: Uuid) -> Result> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT id, user_id, sender_email, subject, body, is_read, status, created_at, \ + received_at, deleted_at FROM user_messages WHERE user_id = $1 AND status != \ + 'deleted' ORDER BY created_at DESC" + } + DatabaseType::SQLite => { + "SELECT id, user_id, sender_email, subject, body, is_read, status, created_at, \ + received_at, deleted_at FROM user_messages WHERE user_id = ?1 AND status != \ + 'deleted' ORDER BY created_at DESC" + } + }; + let rows = self + .database + .fetch_all(query, &[DatabaseParam::Uuid(user_id)]) + .await?; + rows.iter() + .map(|r| { + let status_str = r.get_string("status").unwrap_or_default(); + let status = status_str + .parse::() + .unwrap_or(MessageStatus::Sent); + let received_at = r + .get_optional_string("received_at")? + .filter(|s| !s.is_empty()) + .map(|s| parse_sqlite_datetime(&s)); + let deleted_at = r + .get_optional_string("deleted_at")? + .filter(|s| !s.is_empty()) + .map(|s| parse_sqlite_datetime(&s)); + Ok(MessageRow { + id: r.get_uuid("id")?, + user_id: r.get_uuid("user_id")?, + sender_email: r.get_string("sender_email")?, + subject: r.get_string("subject")?, + body: r.get_string("body")?, + is_read: r.get_bool("is_read")?, + status, + created_at: r.get_datetime("created_at")?, + received_at, + deleted_at, + }) + }) + .collect() + } + + /// Mark a message as read, recording the reception timestamp. + pub async fn mark_message_read(&self, message_id: Uuid, user_id: Uuid) -> Result<()> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "UPDATE user_messages SET is_read = true, status = 'read', received_at = NOW() \ + WHERE id = $1 AND user_id = $2 AND status = 'sent'" + } + DatabaseType::SQLite => { + "UPDATE user_messages SET is_read = 1, status = 'read', received_at = \ + datetime('now') WHERE id = ?1 AND user_id = ?2 AND status = 'sent'" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(message_id), + DatabaseParam::Uuid(user_id), + ], + ) + .await?; + Ok(()) + } + + /// Soft-delete a message, scoped to the owning user. + pub async fn delete_message(&self, message_id: Uuid, user_id: Uuid) -> Result<()> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "UPDATE user_messages SET status = 'deleted', deleted_at = NOW() WHERE id = $1 AND \ + user_id = $2" + } + DatabaseType::SQLite => { + "UPDATE user_messages SET status = 'deleted', deleted_at = datetime('now') WHERE \ + id = ?1 AND user_id = ?2" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(message_id), + DatabaseParam::Uuid(user_id), + ], + ) + .await?; + Ok(()) + } + + /// Retrieve all notes for a user, most recently updated first. + pub async fn get_notes(&self, user_id: Uuid) -> Result> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT id, user_id, title, content, created_at, updated_at FROM user_notes WHERE \ + user_id = $1 ORDER BY updated_at DESC" + } + DatabaseType::SQLite => { + "SELECT id, user_id, title, content, created_at, updated_at FROM user_notes WHERE \ + user_id = ?1 ORDER BY updated_at DESC" + } + }; + let rows = self + .database + .fetch_all(query, &[DatabaseParam::Uuid(user_id)]) + .await?; + rows.iter() + .map(|r| { + Ok(NoteRow { + id: r.get_uuid("id")?, + user_id: r.get_uuid("user_id")?, + title: r.get_string("title")?, + content: r.get_string("content")?, + created_at: r.get_datetime("created_at")?, + updated_at: r.get_datetime("updated_at")?, + }) + }) + .collect() + } + + /// Create a new note for a user and return its UUID. + pub async fn create_note(&self, user_id: Uuid, title: &str, content: &str) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now(); + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO user_notes (id, user_id, title, content, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6)" + } + DatabaseType::SQLite => { + "INSERT INTO user_notes (id, user_id, title, content, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(id), + DatabaseParam::Uuid(user_id), + DatabaseParam::String(title.to_string()), + DatabaseParam::String(content.to_string()), + DatabaseParam::DateTime(now), + DatabaseParam::DateTime(now), + ], + ) + .await?; + Ok(id) + } + + /// Update an existing note, scoped to the owning user. + pub async fn update_note( + &self, + note_id: Uuid, + user_id: Uuid, + title: &str, + content: &str, + ) -> Result<()> { + let now = Utc::now(); + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "UPDATE user_notes SET title = $1, content = $2, updated_at = $3 WHERE id = $4 AND \ + user_id = $5" + } + DatabaseType::SQLite => { + "UPDATE user_notes SET title = ?1, content = ?2, updated_at = ?3 WHERE id = ?4 AND \ + user_id = ?5" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::String(title.to_string()), + DatabaseParam::String(content.to_string()), + DatabaseParam::DateTime(now), + DatabaseParam::Uuid(note_id), + DatabaseParam::Uuid(user_id), + ], + ) + .await?; + Ok(()) + } + + /// Delete a note, scoped to the owning user. + pub async fn delete_note(&self, note_id: Uuid, user_id: Uuid) -> Result<()> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => "DELETE FROM user_notes WHERE id = $1 AND user_id = $2", + DatabaseType::SQLite => "DELETE FROM user_notes WHERE id = ?1 AND user_id = ?2", + }; + self.database + .execute( + query, + &[DatabaseParam::Uuid(note_id), DatabaseParam::Uuid(user_id)], + ) + .await?; + Ok(()) + } + + /// Insert a message into a user's inbox. Used by `/api/notify` when a + /// `user_id` target is provided so the notification persists across + /// sessions. + pub async fn insert_message( + &self, + user_id: Uuid, + sender_email: &str, + subject: &str, + body: &str, + ) -> Result { + let id = Uuid::new_v4(); + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO user_messages (id, user_id, sender_email, subject, body) VALUES ($1, \ + $2, $3, $4, $5)" + } + DatabaseType::SQLite => { + "INSERT INTO user_messages (id, user_id, sender_email, subject, body) VALUES (?1, \ + ?2, ?3, ?4, ?5)" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(id), + DatabaseParam::Uuid(user_id), + DatabaseParam::String(sender_email.to_owned()), + DatabaseParam::String(subject.to_owned()), + DatabaseParam::String(body.to_owned()), + ], + ) + .await?; + Ok(id) + } + + // ── Service tokens ──────────────────────────────────────────────────────── + + /// Create a new service token. The caller must supply the SHA-256 hex + /// hash of the raw token — the plaintext must never reach this layer. + pub async fn create_service_token( + &self, + name: &str, + token_hash: &str, + created_by: Uuid, + scopes: &[String], + expires_at: Option>, + ) -> Result { + let id = Uuid::new_v4(); + let scopes_json = serde_json::to_string(scopes)?; + + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO service_tokens (id, name, token_hash, created_by, scopes, expires_at) \ + VALUES ($1, $2, $3, $4, $5::text[]::text[], $6)" + } + DatabaseType::SQLite => { + "INSERT INTO service_tokens (id, name, token_hash, created_by, scopes, expires_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)" + } + }; + let expires_param = match expires_at { + Some(dt) => DatabaseParam::String(dt.to_rfc3339()), + None => DatabaseParam::String(String::new()), + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(id), + DatabaseParam::String(name.to_owned()), + DatabaseParam::String(token_hash.to_owned()), + DatabaseParam::Uuid(created_by), + DatabaseParam::String(scopes_json), + expires_param, + ], + ) + .await?; + Ok(id) + } + + /// List all service tokens created by an admin user. + pub async fn list_service_tokens(&self, created_by: Uuid) -> Result> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT id, name, token_hash, created_by, scopes, is_active, last_used_at, \ + expires_at, created_at FROM service_tokens WHERE created_by = $1 ORDER BY \ + created_at DESC" + } + DatabaseType::SQLite => { + "SELECT id, name, token_hash, created_by, scopes, is_active, last_used_at, \ + expires_at, created_at FROM service_tokens WHERE created_by = ?1 ORDER BY \ + created_at DESC" + } + }; + let rows = self + .database + .fetch_all(query, &[DatabaseParam::Uuid(created_by)]) + .await?; + rows.iter().map(|r| self.map_service_token_row(r)).collect() + } + + /// Revoke a service token, scoped to its creator. + pub async fn revoke_service_token(&self, token_id: Uuid, created_by: Uuid) -> Result<()> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "UPDATE service_tokens SET is_active = false WHERE id = $1 AND created_by = $2" + } + DatabaseType::SQLite => { + "UPDATE service_tokens SET is_active = 0 WHERE id = ?1 AND created_by = ?2" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::Uuid(token_id), + DatabaseParam::Uuid(created_by), + ], + ) + .await?; + Ok(()) + } + + /// Validate a service token by its SHA-256 hash. Returns the row if the + /// token is active, not expired, and has the requested scope. + /// Updates `last_used_at` on success. + pub async fn validate_service_token( + &self, + token_hash: &str, + required_scope: &str, + ) -> Result> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT id, name, token_hash, created_by, scopes, is_active, last_used_at, \ + expires_at, created_at FROM service_tokens WHERE token_hash = $1 AND is_active = \ + true AND (expires_at IS NULL OR expires_at > NOW())" + } + DatabaseType::SQLite => { + "SELECT id, name, token_hash, created_by, scopes, is_active, last_used_at, \ + expires_at, created_at FROM service_tokens WHERE token_hash = ?1 AND is_active = \ + 1 AND (expires_at IS NULL OR expires_at > datetime('now'))" + } + }; + let rows = self + .database + .fetch_all(query, &[DatabaseParam::String(token_hash.to_owned())]) + .await?; + let Some(row) = rows.first() else { + return Ok(None); + }; + let token = self.map_service_token_row(row)?; + + // Check scope membership. + if !token.scopes.iter().any(|s| s == required_scope || s == "*") { + return Ok(None); + } + + // Update last_used_at asynchronously — ignore errors. + let update_q = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "UPDATE service_tokens SET last_used_at = NOW() WHERE id = $1" + } + DatabaseType::SQLite => { + "UPDATE service_tokens SET last_used_at = datetime('now') WHERE id = ?1" + } + }; + let _ = self + .database + .execute(update_q, &[DatabaseParam::Uuid(token.id)]) + .await; + + Ok(Some(token)) + } + + fn map_service_token_row( + &self, + r: &crate::database::connection::DatabaseRow, + ) -> Result { + let scopes_raw = r.get_string("scopes")?; + let scopes: Vec = serde_json::from_str(&scopes_raw).unwrap_or_default(); + let last_used_at = r + .get_optional_string("last_used_at")? + .map(|s| parse_sqlite_datetime(&s)); + let expires_at = r + .get_optional_string("expires_at")? + .filter(|s| !s.is_empty()) + .map(|s| parse_sqlite_datetime(&s)); + Ok(ServiceTokenRow { + id: r.get_uuid("id")?, + name: r.get_string("name")?, + token_hash: r.get_string("token_hash")?, + created_by: r.get_uuid("created_by")?, + scopes, + is_active: r.get_bool("is_active")?, + last_used_at, + expires_at, + created_at: r.get_datetime("created_at")?, + }) + } + + /// Retrieve active resources visible to any of the given roles. + /// + /// `roles` contains the raw role strings from the database (e.g. "user", + /// "admin"). Placeholders are built dynamically; `roles` is an internal + /// value derived from JWT claims — not user input. + pub async fn get_resources_for_roles(&self, roles: &[String]) -> Result> { + if roles.is_empty() { + return Ok(vec![]); + } + let params: Vec = roles + .iter() + .map(|r| DatabaseParam::String(r.clone())) + .collect(); + + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + let placeholders = (1..=roles.len()) + .map(|i| format!("${i}")) + .collect::>() + .join(", "); + format!( + "SELECT id, name, description, url, resource_type, role_name FROM \ + user_resources WHERE role_name IN ({placeholders}) AND is_active = true \ + ORDER BY name" + ) + } + DatabaseType::SQLite => { + let placeholders = (1..=roles.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(", "); + format!( + "SELECT id, name, description, url, resource_type, role_name FROM \ + user_resources WHERE role_name IN ({placeholders}) AND is_active = 1 ORDER \ + BY name" + ) + } + }; + + let rows = self.database.fetch_all(&query, ¶ms).await?; + rows.iter() + .map(|r| { + Ok(ResourceRow { + id: r.get_uuid("id")?, + name: r.get_string("name")?, + description: r.get_optional_string("description")?, + url: r.get_optional_string("url")?, + resource_type: r.get_string("resource_type")?, + role_name: r.get_string("role_name")?, + }) + }) + .collect() + } +} + +/// A row from the `user_bookmarks` table. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BookmarkRow { + pub id: Uuid, + pub user_id: Uuid, + pub content_type: String, + pub content_id: String, + pub content_title: Option, + pub content_url: Option, + pub created_at: DateTime, +} + +/// Request to add a new bookmark. +#[derive(Debug, Clone)] +pub struct AddBookmarkRequest { + pub user_id: Uuid, + pub content_type: String, + pub content_id: String, + pub content_title: Option, + pub content_url: Option, +} + +/// Message status lifecycle. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessageStatus { + Sent, + Read, + Deleted, +} + +impl MessageStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Sent => "sent", + Self::Read => "read", + Self::Deleted => "deleted", + } + } +} + +impl std::str::FromStr for MessageStatus { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "read" => Ok(Self::Read), + "deleted" => Ok(Self::Deleted), + _ => Ok(Self::Sent), + } + } +} + +/// A row from the `user_messages` table. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MessageRow { + pub id: Uuid, + pub user_id: Uuid, + pub sender_email: String, + pub subject: String, + pub body: String, + pub is_read: bool, + pub status: MessageStatus, + pub created_at: DateTime, + pub received_at: Option>, + pub deleted_at: Option>, +} + +/// A row from the `user_notes` table. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct NoteRow { + pub id: Uuid, + pub user_id: Uuid, + pub title: String, + pub content: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A row from the `user_resources` table. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ResourceRow { + pub id: Uuid, + pub name: String, + pub description: Option, + pub url: Option, + pub resource_type: String, + pub role_name: String, +} + +/// A row from the `service_tokens` table. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceTokenRow { + pub id: Uuid, + pub name: String, + pub token_hash: String, + pub created_by: Uuid, + pub scopes: Vec, + pub is_active: bool, + pub last_used_at: Option>, + pub expires_at: Option>, + pub created_at: DateTime, +} + +/// Parses a datetime string stored by SQLite (`YYYY-MM-DD HH:MM:SS`, no +/// timezone) or in RFC3339 format. SQLite's `datetime('now')` produces the +/// space-separated form; older rows may already be RFC3339. Always returns +/// UTC. +fn parse_sqlite_datetime(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|ndt| ndt.and_utc())) + .unwrap_or_else(|_| { + tracing::warn!( + field = s, + "unrecognised datetime format, substituting epoch" + ); + DateTime::UNIX_EPOCH + }) } #[async_trait] @@ -590,7 +1376,7 @@ impl AuthRepository { async fn create_user_postgres(&self, user: &CreateUserRequest) -> Result { let now = Utc::now(); let id = Uuid::new_v4(); - let roles = serde_json::to_string(&vec!["user".to_string()])?; + let roles = serde_json::to_string(&vec![self.registration_role.clone()])?; let query = r#" INSERT INTO users (id, email, username, display_name, password_hash, roles, is_active, is_verified, email_verified, created_at, updated_at) @@ -783,7 +1569,7 @@ impl AuthRepository { async fn create_user_sqlite(&self, user: &CreateUserRequest) -> Result { let now = Utc::now(); let id = Uuid::new_v4(); - let roles = serde_json::to_string(&vec!["user".to_string()])?; + let roles = serde_json::to_string(&vec![self.registration_role.clone()])?; let query = r#" INSERT INTO users (id, email, username, display_name, password_hash, roles, is_active, is_verified, email_verified, created_at, updated_at) @@ -813,7 +1599,7 @@ impl AuthRepository { display_name: user.display_name.clone(), password_hash: user.password_hash.clone(), avatar_url: None, - roles: vec!["user".to_string()], + roles: vec![self.registration_role.clone()], is_active: user.is_active, is_verified: user.is_verified, email_verified: user.is_verified, @@ -846,17 +1632,12 @@ impl AuthRepository { is_active: r.get_bool("is_active").unwrap(), is_verified: r.get_bool("is_verified").unwrap(), email_verified: r.get_bool("email_verified").unwrap(), - created_at: DateTime::parse_from_rfc3339(&r.get_string("created_at").unwrap()) + created_at: parse_sqlite_datetime(&r.get_string("created_at").unwrap()), + updated_at: parse_sqlite_datetime(&r.get_string("updated_at").unwrap()), + last_login: r + .get_optional_string("last_login") .unwrap() - .with_timezone(&Utc), - updated_at: DateTime::parse_from_rfc3339(&r.get_string("updated_at").unwrap()) - .unwrap() - .with_timezone(&Utc), - last_login: r.get_optional_string("last_login").unwrap().map(|s| { - DateTime::parse_from_rfc3339(&s) - .unwrap() - .with_timezone(&Utc) - }), + .map(|s| parse_sqlite_datetime(&s)), two_factor_enabled: r.get_bool("two_factor_enabled").unwrap(), two_factor_secret: r.get_optional_string("two_factor_secret").unwrap(), backup_codes: serde_json::from_str( @@ -888,17 +1669,12 @@ impl AuthRepository { is_active: r.get_bool("is_active").unwrap(), is_verified: r.get_bool("is_verified").unwrap(), email_verified: r.get_bool("email_verified").unwrap(), - created_at: DateTime::parse_from_rfc3339(&r.get_string("created_at").unwrap()) + created_at: parse_sqlite_datetime(&r.get_string("created_at").unwrap()), + updated_at: parse_sqlite_datetime(&r.get_string("updated_at").unwrap()), + last_login: r + .get_optional_string("last_login") .unwrap() - .with_timezone(&Utc), - updated_at: DateTime::parse_from_rfc3339(&r.get_string("updated_at").unwrap()) - .unwrap() - .with_timezone(&Utc), - last_login: r.get_optional_string("last_login").unwrap().map(|s| { - DateTime::parse_from_rfc3339(&s) - .unwrap() - .with_timezone(&Utc) - }), + .map(|s| parse_sqlite_datetime(&s)), two_factor_enabled: r.get_bool("two_factor_enabled").unwrap(), two_factor_secret: r.get_optional_string("two_factor_secret").unwrap(), backup_codes: serde_json::from_str( diff --git a/crates/foundation/crates/rustelo_server/src/database/connection.rs b/crates/foundation/crates/rustelo_server/src/database/connection.rs index 3492a11..94f462d 100644 --- a/crates/foundation/crates/rustelo_server/src/database/connection.rs +++ b/crates/foundation/crates/rustelo_server/src/database/connection.rs @@ -351,7 +351,7 @@ impl PostgreSQLConnection { } pub async fn execute(&self, query: &str, params: &[DatabaseParam]) -> Result { - let mut sqlx_query = sqlx::query(query); + let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query)); for param in params { match param { @@ -379,7 +379,7 @@ impl PostgreSQLConnection { } pub async fn fetch_one(&self, query: &str, params: &[DatabaseParam]) -> Result { - let mut sqlx_query = sqlx::query(query); + let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query)); for param in params { match param { @@ -411,7 +411,7 @@ impl PostgreSQLConnection { query: &str, params: &[DatabaseParam], ) -> Result> { - let mut sqlx_query = sqlx::query(query); + let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query)); for param in params { match param { @@ -443,7 +443,7 @@ impl PostgreSQLConnection { query: &str, params: &[DatabaseParam], ) -> Result> { - let mut sqlx_query = sqlx::query(query); + let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query)); for param in params { match param { @@ -483,7 +483,7 @@ impl SQLiteConnection { } pub async fn execute(&self, query: &str, params: &[DatabaseParam]) -> Result { - let mut sqlx_query = sqlx::query(query); + let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query)); for param in params { match param { @@ -515,7 +515,7 @@ impl SQLiteConnection { } pub async fn fetch_one(&self, query: &str, params: &[DatabaseParam]) -> Result { - let mut sqlx_query = sqlx::query(query); + let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query)); for param in params { match param { @@ -551,7 +551,7 @@ impl SQLiteConnection { query: &str, params: &[DatabaseParam], ) -> Result> { - let mut sqlx_query = sqlx::query(query); + let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query)); for param in params { match param { @@ -583,7 +583,7 @@ impl SQLiteConnection { } pub async fn fetch_all(&self, query: &str, params: &[DatabaseParam]) -> Result> { - let mut sqlx_query = sqlx::query(query); + let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query)); for param in params { match param { diff --git a/crates/foundation/crates/rustelo_server/src/database/engagement.rs b/crates/foundation/crates/rustelo_server/src/database/engagement.rs new file mode 100644 index 0000000..a8694e2 --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/database/engagement.rs @@ -0,0 +1,402 @@ +//! Per-post engagement repository: unique views, 1–5 ratings, and messages. +//! +//! Mirrors [`crate::database::auth::AuthRepository`]: it holds a +//! [`DatabaseConnection`], dispatches backend-specific SQL on +//! [`DatabaseConnection::database_type`], and marshals values through +//! [`DatabaseParam`]. Visitor identity is an opaque `visitor_id` — the +//! first-party `vid` cookie minted by the request handler — so no raw IP or +//! user-agent is ever persisted. +//! +//! Aggregation note: [`DatabaseRow`](crate::database::DatabaseRow) exposes no +//! floating-point getter, so the rating mean is computed in Rust from `SUM` and +//! `COUNT` (both integers on SQLite and PostgreSQL) rather than read from +//! `AVG()`. + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::database::connection::{DatabaseConnection, DatabaseParam}; +use crate::database::{DatabasePool, DatabaseType}; + +/// Aggregate rating for a post: arithmetic mean and number of distinct voters. +#[derive(Debug, Clone, PartialEq)] +pub struct RatingSummary { + /// Mean of all votes, or `0.0` when there are none. + pub average: f64, + /// Number of distinct voters (one row per `visitor_id`). + pub count: i64, +} + +/// A message to persist for a post, awaiting moderation or later promotion to a +/// public comment. `status` is always stored as `new`. +#[derive(Debug, Clone)] +pub struct NewPostMessage { + pub post_id: String, + pub post_title: String, + pub lang: String, + pub name: String, + pub email: Option, + pub body: String, + pub visitor_id: Option, + /// Whether the visitor authorised publishing the comment alongside the + /// post. + pub publish_consent: bool, + /// When consent was given (proof of consent). `None` unless + /// `publish_consent`. + pub consent_at: Option>, + /// Client IP at consent time (proof of consent). `None` unless available. + pub consent_ip: Option, +} + +/// Database-agnostic repository for per-post engagement. +#[derive(Debug, Clone)] +pub struct EngagementRepository { + database: DatabaseConnection, +} + +impl EngagementRepository { + /// Create a repository over an existing connection. + pub fn new(database: DatabaseConnection) -> Self { + Self { database } + } + + /// Create from a database pool (convenience, mirrors `AuthRepository`). + pub fn from_pool(pool: &DatabasePool) -> Self { + Self::new(DatabaseConnection::from_pool(pool)) + } + + /// The backend this repository talks to. + pub fn database_type(&self) -> DatabaseType { + self.database.database_type() + } + + // ── Views ────────────────────────────────────────────────────────────── + + /// Record a view of `post_id` in `lang` by `visitor_id`, then return the + /// resulting unique-view count for `(post_id, lang)`. + /// + /// Idempotent per `(post_id, lang, visitor_id)`: a repeat view by the same + /// visitor leaves the count unchanged (`INSERT … ON CONFLICT DO NOTHING`). + pub async fn record_view(&self, post_id: &str, lang: &str, visitor_id: &str) -> Result { + let insert = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO post_view (post_id, lang, visitor_id, first_seen) VALUES ($1, $2, $3, \ + $4) ON CONFLICT (post_id, lang, visitor_id) DO NOTHING" + } + DatabaseType::SQLite => { + "INSERT OR IGNORE INTO post_view (post_id, lang, visitor_id, first_seen) VALUES \ + (?1, ?2, ?3, ?4)" + } + }; + self.database + .execute( + insert, + &[ + DatabaseParam::String(post_id.to_string()), + DatabaseParam::String(lang.to_string()), + DatabaseParam::String(visitor_id.to_string()), + DatabaseParam::DateTime(Utc::now()), + ], + ) + .await?; + self.view_count(post_id, lang).await + } + + /// Unique-view count for `(post_id, lang)`. + pub async fn view_count(&self, post_id: &str, lang: &str) -> Result { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT COUNT(*) AS n FROM post_view WHERE post_id = $1 AND lang = $2" + } + DatabaseType::SQLite => { + "SELECT COUNT(*) AS n FROM post_view WHERE post_id = ?1 AND lang = ?2" + } + }; + let row = self + .database + .fetch_one( + query, + &[ + DatabaseParam::String(post_id.to_string()), + DatabaseParam::String(lang.to_string()), + ], + ) + .await?; + row.get_i64("n") + } + + // ── Ratings ──────────────────────────────────────────────────────────── + + /// Upsert a 1–5 rating for `(post_id, visitor_id)`, then return the updated + /// aggregate. A repeat vote by the same visitor updates the existing value + /// rather than adding a row. + /// + /// Out-of-range values are rejected before the query runs; the SQL `CHECK` + /// constraint is the defence-in-depth backstop. + pub async fn record_rating( + &self, + post_id: &str, + visitor_id: &str, + value: i32, + ) -> Result { + if !(1..=5).contains(&value) { + anyhow::bail!("rating value must be between 1 and 5, got {value}"); + } + let upsert = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO post_rating (post_id, visitor_id, value, updated_at) VALUES ($1, $2, \ + $3, $4) ON CONFLICT (post_id, visitor_id) DO UPDATE SET value = EXCLUDED.value, \ + updated_at = EXCLUDED.updated_at" + } + DatabaseType::SQLite => { + "INSERT INTO post_rating (post_id, visitor_id, value, updated_at) VALUES (?1, ?2, \ + ?3, ?4) ON CONFLICT (post_id, visitor_id) DO UPDATE SET value = excluded.value, \ + updated_at = excluded.updated_at" + } + }; + self.database + .execute( + upsert, + &[ + DatabaseParam::String(post_id.to_string()), + DatabaseParam::String(visitor_id.to_string()), + DatabaseParam::I32(value), + DatabaseParam::DateTime(Utc::now()), + ], + ) + .await?; + self.rating_summary(post_id).await + } + + /// Aggregate rating for a post. Returns `{ average: 0.0, count: 0 }` when + /// there are no votes. + pub async fn rating_summary(&self, post_id: &str) -> Result { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT COUNT(*) AS n, COALESCE(SUM(value), 0) AS s FROM post_rating WHERE post_id \ + = $1" + } + DatabaseType::SQLite => { + "SELECT COUNT(*) AS n, COALESCE(SUM(value), 0) AS s FROM post_rating WHERE post_id \ + = ?1" + } + }; + let row = self + .database + .fetch_one(query, &[DatabaseParam::String(post_id.to_string())]) + .await?; + let count = row.get_i64("n")?; + let sum = row.get_i64("s")?; + let average = if count > 0 { + sum as f64 / count as f64 + } else { + 0.0 + }; + Ok(RatingSummary { average, count }) + } + + /// The value this visitor rated the post, if any. Used to highlight the + /// caller's own star on load. + pub async fn visitor_rating(&self, post_id: &str, visitor_id: &str) -> Result> { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "SELECT value FROM post_rating WHERE post_id = $1 AND visitor_id = $2" + } + DatabaseType::SQLite => { + "SELECT value FROM post_rating WHERE post_id = ?1 AND visitor_id = ?2" + } + }; + let row = self + .database + .fetch_optional( + query, + &[ + DatabaseParam::String(post_id.to_string()), + DatabaseParam::String(visitor_id.to_string()), + ], + ) + .await?; + match row { + Some(r) => Ok(Some(r.get_i32("value")?)), + None => Ok(None), + } + } + + // ── Messages ─────────────────────────────────────────────────────────── + + /// Persist a `new`-status message for a post and return its generated id. + /// Persistence is independent of owner notification, so a message is never + /// lost even when email delivery is disabled. + pub async fn insert_message(&self, msg: &NewPostMessage) -> Result { + let id = Uuid::new_v4(); + // Store the id as text: `post_message.id` is TEXT on both backends, so + // bind a String rather than a native UUID to avoid backend type coercion. + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => { + "INSERT INTO post_message (id, post_id, post_title, lang, name, email, body, \ + status, visitor_id, created_at, publish_consent, consent_at, consent_ip) VALUES \ + ($1, $2, $3, $4, $5, $6, $7, 'new', $8, $9, $10, $11, $12)" + } + DatabaseType::SQLite => { + "INSERT INTO post_message (id, post_id, post_title, lang, name, email, body, \ + status, visitor_id, created_at, publish_consent, consent_at, consent_ip) VALUES \ + (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'new', ?8, ?9, ?10, ?11, ?12)" + } + }; + self.database + .execute( + query, + &[ + DatabaseParam::String(id.to_string()), + DatabaseParam::String(msg.post_id.clone()), + DatabaseParam::String(msg.post_title.clone()), + DatabaseParam::String(msg.lang.clone()), + DatabaseParam::String(msg.name.clone()), + DatabaseParam::OptionalString(msg.email.clone()), + DatabaseParam::String(msg.body.clone()), + DatabaseParam::OptionalString(msg.visitor_id.clone()), + DatabaseParam::DateTime(Utc::now()), + DatabaseParam::Bool(msg.publish_consent), + DatabaseParam::OptionalDateTime(msg.consent_at), + DatabaseParam::OptionalString(msg.consent_ip.clone()), + ], + ) + .await?; + Ok(id) + } + + /// Number of messages stored for a post, across all statuses. + pub async fn message_count(&self, post_id: &str) -> Result { + let query = match self.database.database_type() { + DatabaseType::PostgreSQL => "SELECT COUNT(*) AS n FROM post_message WHERE post_id = $1", + DatabaseType::SQLite => "SELECT COUNT(*) AS n FROM post_message WHERE post_id = ?1", + }; + let row = self + .database + .fetch_one(query, &[DatabaseParam::String(post_id.to_string())]) + .await?; + row.get_i64("n") + } +} + +#[cfg(test)] +mod tests { + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + + /// A single-connection in-memory SQLite repo with the engagement schema + /// applied. `max_connections(1)` keeps every query on the one connection so + /// the in-memory database is shared across calls. + async fn mem_repo() -> EngagementRepository { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + + for stmt in [ + "CREATE TABLE post_view (post_id TEXT NOT NULL, lang TEXT NOT NULL, visitor_id TEXT \ + NOT NULL, first_seen TEXT NOT NULL, PRIMARY KEY (post_id, lang, visitor_id));", + "CREATE TABLE post_rating (post_id TEXT NOT NULL, visitor_id TEXT NOT NULL, value \ + INTEGER NOT NULL CHECK (value BETWEEN 1 AND 5), updated_at TEXT NOT NULL, PRIMARY \ + KEY (post_id, visitor_id));", + "CREATE TABLE post_message (id TEXT PRIMARY KEY, post_id TEXT NOT NULL, post_title \ + TEXT NOT NULL, lang TEXT NOT NULL, name TEXT NOT NULL, email TEXT, body TEXT NOT \ + NULL, status TEXT NOT NULL DEFAULT 'new', visitor_id TEXT, created_at TEXT NOT NULL, \ + publish_consent INTEGER NOT NULL DEFAULT 0, consent_at TEXT, consent_ip TEXT);", + ] { + sqlx::query(stmt).execute(&pool).await.expect("apply ddl"); + } + + EngagementRepository::from_pool(&DatabasePool::SQLite(pool)) + } + + #[tokio::test] + async fn view_counts_unique_visitors_and_scopes_by_lang() { + let repo = mem_repo().await; + + // Same visitor, same post/lang → counts once. + assert_eq!(repo.record_view("p1", "en", "vA").await.unwrap(), 1); + assert_eq!(repo.record_view("p1", "en", "vA").await.unwrap(), 1); + + // A different visitor increments. + assert_eq!(repo.record_view("p1", "en", "vB").await.unwrap(), 2); + + // A different language is a separate bucket. + assert_eq!(repo.record_view("p1", "es", "vA").await.unwrap(), 1); + assert_eq!(repo.view_count("p1", "en").await.unwrap(), 2); + } + + #[tokio::test] + async fn rating_upsert_updates_in_place() { + let repo = mem_repo().await; + + // First vote. + let s = repo.record_rating("p1", "vA", 5).await.unwrap(); + assert_eq!(s.count, 1); + assert_eq!(s.average, 5.0); + + // Same visitor re-rates: updates, does not duplicate. + let s = repo.record_rating("p1", "vA", 3).await.unwrap(); + assert_eq!(s.count, 1); + assert_eq!(s.average, 3.0); + + // Second visitor: avg (3 + 5) / 2 = 4.0. + let s = repo.record_rating("p1", "vB", 5).await.unwrap(); + assert_eq!(s.count, 2); + assert_eq!(s.average, 4.0); + + // Each visitor can read back their own current vote. + assert_eq!(repo.visitor_rating("p1", "vA").await.unwrap(), Some(3)); + assert_eq!(repo.visitor_rating("p1", "vB").await.unwrap(), Some(5)); + assert_eq!(repo.visitor_rating("p1", "vC").await.unwrap(), None); + } + + #[tokio::test] + async fn rating_rejects_out_of_range() { + let repo = mem_repo().await; + assert!(repo.record_rating("p1", "vA", 0).await.is_err()); + assert!(repo.record_rating("p1", "vA", 6).await.is_err()); + assert!(repo.record_rating("p1", "vA", -1).await.is_err()); + // Nothing persisted after rejected writes. + assert_eq!(repo.rating_summary("p1").await.unwrap().count, 0); + } + + #[tokio::test] + async fn empty_rating_summary_is_zero() { + let repo = mem_repo().await; + let s = repo.rating_summary("nope").await.unwrap(); + assert_eq!(s.count, 0); + assert_eq!(s.average, 0.0); + } + + #[tokio::test] + async fn message_persists_and_counts() { + let repo = mem_repo().await; + let msg = NewPostMessage { + post_id: "p1".to_string(), + post_title: "Hello".to_string(), + lang: "en".to_string(), + name: "Ada".to_string(), + email: Some("ada@example.com".to_string()), + body: "nice post".to_string(), + visitor_id: Some("vA".to_string()), + publish_consent: true, + consent_at: Some(Utc::now()), + consent_ip: Some("203.0.113.7".to_string()), + }; + let id = repo.insert_message(&msg).await.unwrap(); + assert!(!id.is_nil()); + assert_eq!(repo.message_count("p1").await.unwrap(), 1); + assert_eq!(repo.message_count("other").await.unwrap(), 0); + + // A second message with no email still stores. + let mut anon = msg.clone(); + anon.email = None; + anon.visitor_id = None; + repo.insert_message(&anon).await.unwrap(); + assert_eq!(repo.message_count("p1").await.unwrap(), 2); + } +} diff --git a/crates/foundation/crates/rustelo_server/src/database/migrations.rs b/crates/foundation/crates/rustelo_server/src/database/migrations.rs index 509f125..db2a0f1 100644 --- a/crates/foundation/crates/rustelo_server/src/database/migrations.rs +++ b/crates/foundation/crates/rustelo_server/src/database/migrations.rs @@ -606,7 +606,7 @@ impl MigrationRunner { migration.name ); - sqlx::query(&migration.up_sql_postgres) + sqlx::query(sqlx::AssertSqlSafe(migration.up_sql_postgres.as_str())) .execute(pool) .await?; @@ -643,7 +643,9 @@ impl MigrationRunner { migration.name ); - sqlx::query(&migration.up_sql_sqlite).execute(pool).await?; + sqlx::query(sqlx::AssertSqlSafe(migration.up_sql_sqlite.as_str())) + .execute(pool) + .await?; sqlx::query("INSERT INTO _migrations (version, name) VALUES (?1, ?2)") .bind(migration.version) @@ -786,9 +788,12 @@ impl MigrationRunner { // Drop all tables for row in rows { let table_name: String = row.get("name"); - sqlx::query(&format!("DROP TABLE IF EXISTS {};", table_name)) - .execute(pool) - .await?; + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP TABLE IF EXISTS {};", + table_name + ))) + .execute(pool) + .await?; } Ok(()) diff --git a/crates/foundation/crates/rustelo_server/src/database/mod.rs b/crates/foundation/crates/rustelo_server/src/database/mod.rs index 6e7ae81..f4a2d35 100644 --- a/crates/foundation/crates/rustelo_server/src/database/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/database/mod.rs @@ -11,8 +11,11 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row, SqlitePool}; use uuid::Uuid; +pub mod activity; pub mod auth; pub mod connection; +#[cfg(feature = "post-engagement")] +pub mod engagement; pub mod migrations; pub mod rbac; @@ -25,6 +28,9 @@ pub struct DatabaseConfig { pub connect_timeout: Duration, pub idle_timeout: Duration, pub max_lifetime: Duration, + /// Create the SQLite database file on first connection if it does not + /// exist. Ignored for PostgreSQL (Postgres databases must pre-exist). + pub create_if_missing: bool, } /// Database type enumeration @@ -59,21 +65,46 @@ impl DatabasePool { Ok(DatabasePool::PostgreSQL(pool)) } DatabaseType::SQLite => { - // Ensure directory exists for SQLite - if let Some(path) = config.url.strip_prefix("sqlite:") { - if let Some(parent) = std::path::Path::new(path).parent() { + // Strip query parameters to get the filesystem path, then ensure the + // parent directory exists before SQLite tries to open the file. + let db_path_str = config + .url + .strip_prefix("sqlite:") + .unwrap_or("") + .split('?') + .next() + .unwrap_or(""); + let db_path = std::path::Path::new(db_path_str); + if let Some(parent) = db_path.parent() { + if !parent.as_os_str().is_empty() { tokio::fs::create_dir_all(parent).await?; } } + let connect_opts = sqlx::sqlite::SqliteConnectOptions::new() + .filename(db_path) + .create_if_missing(config.create_if_missing) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); + let pool = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(config.max_connections) .min_connections(config.min_connections) .acquire_timeout(config.connect_timeout) .idle_timeout(config.idle_timeout) .max_lifetime(config.max_lifetime) - .connect(&config.url) + .connect_with(connect_opts) .await?; + + // Run migrations immediately so tables exist before any service starts. + // This is idempotent — already-applied migrations are skipped. + { + use crate::migrations::MigrationRunner; + let runner = MigrationRunner::new(); + if let Err(e) = runner.run_sqlite_migrations(&pool).await { + tracing::warn!("SQLite migration error (non-fatal): {}", e); + } + } + Ok(DatabasePool::SQLite(pool)) } } diff --git a/crates/foundation/crates/rustelo_server/src/email/contact_adapter.rs b/crates/foundation/crates/rustelo_server/src/email/contact_adapter.rs new file mode 100644 index 0000000..5d2cca6 --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/email/contact_adapter.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use rustelo_components_leptos::contact::facade::server::{BoxFuture, ContactFormFacade}; + +use super::{EmailService, FormSubmission}; + +/// Concrete adapter connecting `ContactFormFacade` (defined in +/// `rustelo_components_leptos`) to the `EmailService` (defined here in +/// `rustelo_server`). +/// +/// Registered at startup via +/// `axum::Extension(ContactFormExtension(Arc::new(adapter)))`. +pub struct ContactFormAdapter { + pub email_service: Arc, + /// Destination address for all incoming contact/work-request submissions. + pub recipient: String, +} + +impl ContactFormFacade for ContactFormAdapter { + fn send( + &self, + name: String, + email: String, + subject: String, + message: String, + ) -> BoxFuture> { + let svc = self.email_service.clone(); + let recipient = self.recipient.clone(); + Box::pin(async move { + let submission = FormSubmission::new("contact", &name, &email, &subject, &message); + svc.send_form_submission_with_language(&submission, &recipient, "en") + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + } +} diff --git a/crates/foundation/crates/rustelo_server/src/email/mod.rs b/crates/foundation/crates/rustelo_server/src/email/mod.rs index c2ac09a..1abc078 100644 --- a/crates/foundation/crates/rustelo_server/src/email/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/email/mod.rs @@ -2,16 +2,20 @@ //! //! This module provides a comprehensive email system that supports: //! - Multiple email providers (SMTP, SendGrid, etc.) -//! - Template-based emails with Handlebars +//! - Template-based emails with Tera //! - Form submissions and contact forms //! - Email notifications //! - Secure configuration management +#[cfg(feature = "leptos-hydration")] +pub mod contact_adapter; pub mod providers; pub mod service; pub mod templates; pub mod types; +#[cfg(feature = "leptos-hydration")] +pub use contact_adapter::ContactFormAdapter; pub use providers::{ConsoleProvider, SendGridProvider, SmtpProvider}; pub use service::{EmailService, EmailServiceBuilder}; pub use templates::EmailTemplateEngine; diff --git a/crates/foundation/crates/rustelo_server/src/email/service.rs b/crates/foundation/crates/rustelo_server/src/email/service.rs index 47502ba..1c97a73 100644 --- a/crates/foundation/crates/rustelo_server/src/email/service.rs +++ b/crates/foundation/crates/rustelo_server/src/email/service.rs @@ -45,8 +45,13 @@ impl EmailService { EmailProvider::Console => Arc::new(ConsoleProvider::new()), }; - // Create template engine with default language - let template_engine = EmailTemplateEngine::new_with_language(&config.template_dir, "en")?; + // Skip template engine creation when email is disabled — avoids requiring + // the template directory to exist for disabled-email deployments. + let template_engine = if config.enabled { + EmailTemplateEngine::new_with_language(&config.template_dir, "en")? + } else { + EmailTemplateEngine::new_empty() + }; let template_engine = Arc::new(RwLock::new(template_engine)); info!( diff --git a/crates/foundation/crates/rustelo_server/src/email/templates.rs b/crates/foundation/crates/rustelo_server/src/email/templates.rs index b053952..ad11134 100644 --- a/crates/foundation/crates/rustelo_server/src/email/templates.rs +++ b/crates/foundation/crates/rustelo_server/src/email/templates.rs @@ -1,61 +1,81 @@ -//! Email template engine using Handlebars +//! Email template engine using Tera //! -//! This module provides template rendering capabilities for emails using -//! Handlebars templating engine. It supports both text and HTML templates with -//! custom helpers. +//! Supports both text and HTML templates with Tera's built-in filters +//! (`capitalize`, `truncate`, `default`, `urlencode`, etc.). +//! +//! Template naming convention: `{lang}_{stem}_{type}` where: +//! - `lang` is the language code (e.g. `en`, `es`) +//! - `stem` is the template filename without extension +//! - `type` is `html` or `text` +//! +//! Directory layout (either form is accepted): +//! ```text +//! template_dir/ +//! html/otp_code.html → registered as `en_otp_code_html` +//! text/otp_code.txt → registered as `en_otp_code_text` +//! ``` +//! or language-prefixed: +//! ```text +//! template_dir/ +//! en_/html/otp_code.html +//! es_/html/otp_code.html +//! ``` #![allow(dead_code)] use std::collections::HashMap; use std::path::Path; -use handlebars::{Context, Handlebars, Helper, HelperResult, Output, RenderContext}; use serde_json::Value; +use tera::{Context, Tera}; use crate::email::{EmailError, EmailResult}; -/// Email template engine -#[derive(Debug)] +/// Email template engine backed by Tera. pub struct EmailTemplateEngine { - handlebars: Handlebars<'static>, + tera: Tera, template_dir: String, default_language: String, + /// Raw template sources kept for rebuild on unregister/clear. + raw_templates: HashMap, +} + +impl std::fmt::Debug for EmailTemplateEngine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmailTemplateEngine") + .field("template_dir", &self.template_dir) + .field("default_language", &self.default_language) + .field("template_count", &self.raw_templates.len()) + .finish() + } } -#[allow(dead_code)] impl EmailTemplateEngine { - /// Create a new template engine pub fn new(template_dir: &str) -> EmailResult { Self::new_with_language(template_dir, "en") } - /// Create a new template engine with default language + /// Empty engine for disabled-email mode — no template dir required. + pub fn new_empty() -> Self { + Self { + tera: Tera::default(), + template_dir: String::new(), + default_language: "en".to_string(), + raw_templates: HashMap::new(), + } + } + pub fn new_with_language(template_dir: &str, default_language: &str) -> EmailResult { - let mut handlebars = Handlebars::new(); - - // Register custom helpers - handlebars.register_helper("date_format", Box::new(date_format_helper)); - handlebars.register_helper("capitalize", Box::new(capitalize_helper)); - handlebars.register_helper("truncate", Box::new(truncate_helper)); - handlebars.register_helper("default", Box::new(default_helper)); - handlebars.register_helper("url_encode", Box::new(url_encode_helper)); - - // Set strict mode to catch template errors - handlebars.set_strict_mode(true); - let mut engine = Self { - handlebars, + tera: Tera::default(), template_dir: template_dir.to_string(), default_language: default_language.to_string(), + raw_templates: HashMap::new(), }; - - // Load all templates from directory engine.load_templates()?; - Ok(engine) } - /// Load templates from the template directory pub fn load_templates(&mut self) -> EmailResult<()> { let template_dir_clone = self.template_dir.clone(); let template_path = Path::new(&template_dir_clone); @@ -67,48 +87,39 @@ impl EmailTemplateEngine { ))); } - // Scan for language directories - let entries = std::fs::read_dir(template_path).map_err(|e| { - EmailError::template(&format!("Failed to read template directory: {}", e)) - })?; + let entries_vec: Vec<_> = std::fs::read_dir(template_path) + .map_err(|e| { + EmailError::template(&format!("Failed to read template directory: {}", e)) + })? + .collect::, _>>() + .map_err(|e| { + EmailError::template(&format!("Failed to read directory entries: {}", e)) + })?; let mut found_templates = false; - // Collect all language directories first to avoid borrowing issues - let language_dirs: Vec<(std::path::PathBuf, String)> = Vec::new(); - - let entries_vec: Vec<_> = entries.collect::, _>>().map_err(|e| { - EmailError::template(&format!("Failed to read directory entries: {}", e)) - })?; - for entry in entries_vec { let path = entry.path(); if path.is_dir() { if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { - // Check if this is a language directory (e.g., en_, es_, fr_) if dir_name.ends_with('_') { let lang_code = dir_name[..dir_name.len() - 1].to_string(); - let path_clone = path.clone(); - self.load_language_templates(&path_clone, &lang_code)?; + self.load_language_templates(&path.clone(), &lang_code)?; found_templates = true; } } } } - // If no language-specific templates found, try loading from direct - // subdirectories if !found_templates { let default_lang = self.default_language.clone(); - // Load HTML templates let html_dir = template_path.join("html"); if html_dir.exists() { self.load_templates_from_dir(&html_dir, &default_lang, "html")?; found_templates = true; } - // Load text templates let text_dir = template_path.join("text"); if text_dir.exists() { self.load_templates_from_dir(&text_dir, &default_lang, "text")?; @@ -117,33 +128,33 @@ impl EmailTemplateEngine { } if !found_templates { - tracing::warn!( - "No email templates found in directory: {}", - self.template_dir - ); + let short = std::env::current_dir() + .ok() + .and_then(|cwd| { + template_path + .strip_prefix(&cwd) + .ok() + .map(|p| p.to_path_buf()) + }) + .unwrap_or_else(|| template_path.to_path_buf()); + tracing::warn!("No email templates found in directory: {}", short.display()); } Ok(()) } - /// Load templates for a specific language fn load_language_templates(&mut self, lang_dir: &Path, lang_code: &str) -> EmailResult<()> { - // Load HTML templates let html_dir = lang_dir.join("html"); if html_dir.exists() { self.load_templates_from_dir(&html_dir, lang_code, "html")?; } - - // Load text templates let text_dir = lang_dir.join("text"); if text_dir.exists() { self.load_templates_from_dir(&text_dir, lang_code, "text")?; } - Ok(()) } - /// Load templates from a specific directory fn load_templates_from_dir( &mut self, dir: &Path, @@ -158,96 +169,86 @@ impl EmailTemplateEngine { let entry = entry.map_err(|e| { EmailError::template(&format!("Failed to read directory entry: {}", e)) })?; - let path = entry.path(); - if path.is_file() { - if let Some(extension) = path.extension() { - if extension == "hbs" || extension == "handlebars" { - let template_name = path - .file_stem() - .ok_or_else(|| EmailError::template("Invalid template filename"))? - .to_string_lossy(); - - let full_template_name = - format!("{}_{}_{}", lang_code, template_name, template_type); - - self.handlebars - .register_template_file(&full_template_name, &path) - .map_err(|e| { - EmailError::template(&format!( - "Failed to register template {}: {}", - full_template_name, e - )) - })?; - - tracing::debug!("Loaded email template: {}", full_template_name); - } - } + if !path.is_file() { + continue; } + + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if !matches!(ext, "html" | "txt" | "tera") { + continue; + } + + let stem = path + .file_stem() + .ok_or_else(|| EmailError::template("Invalid template filename"))? + .to_string_lossy(); + let name = format!("{}_{}_{}", lang_code, stem, template_type); + + let source = std::fs::read_to_string(&path).map_err(|e| { + EmailError::template(&format!( + "Failed to read template file {}: {}", + path.display(), + e + )) + })?; + + self.raw_templates.insert(name.clone(), source.clone()); + self.tera.add_raw_template(&name, &source).map_err(|e| { + EmailError::template(&format!("Failed to register template {}: {}", name, e)) + })?; + + tracing::debug!("Loaded email template: {}", name); } Ok(()) } - /// Render a template with data and language - #[allow(dead_code)] pub fn render( &self, template_name: &str, data: &HashMap, ) -> EmailResult { - self.render_with_language(template_name, data, &self.default_language) + self.render_with_language(template_name, data, &self.default_language.clone()) } - /// Render a template with data and specific language pub fn render_with_language( &self, template_name: &str, data: &HashMap, language: &str, ) -> EmailResult { - let full_template_name = format!("{}_{}", language, template_name); + let ctx = Self::build_context(data)?; - // Try the requested language first - if self.handlebars.has_template(&full_template_name) { - return self - .handlebars - .render(&full_template_name, data) - .map_err(|e| { - EmailError::template(&format!( - "Failed to render template {}: {}", - full_template_name, e - )) - }); + let full = format!("{}_{}", language, template_name); + if self.tera.get_template(&full).is_ok() { + return self.tera.render(&full, &ctx).map_err(|e| { + EmailError::template(&format!("Failed to render template {}: {}", full, e)) + }); } - // Fall back to default language - let default_template_name = format!("{}_{}", self.default_language, template_name); - if self.handlebars.has_template(&default_template_name) { + let default_full = format!("{}_{}", self.default_language, template_name); + if self.tera.get_template(&default_full).is_ok() { tracing::warn!( "Template {} not found for language {}, falling back to {}", template_name, language, self.default_language ); - return self - .handlebars - .render(&default_template_name, data) - .map_err(|e| { - EmailError::template(&format!( - "Failed to render fallback template {}: {}", - default_template_name, e - )) - }); + return self.tera.render(&default_full, &ctx).map_err(|e| { + EmailError::template(&format!( + "Failed to render fallback template {}: {}", + default_full, e + )) + }); } - // Last resort: try template without language prefix (legacy support) - if self.handlebars.has_template(template_name) { + if self.tera.get_template(template_name).is_ok() { tracing::warn!( "Using legacy template without language prefix: {}", template_name ); - return self.handlebars.render(template_name, data).map_err(|e| { + return self.tera.render(template_name, &ctx).map_err(|e| { EmailError::template(&format!( "Failed to render legacy template {}: {}", template_name, e @@ -261,196 +262,82 @@ impl EmailTemplateEngine { ))) } - /// Check if a template exists for any language - #[allow(dead_code)] pub fn has_template(&self, template_name: &str) -> bool { - self.has_template_for_language(template_name, &self.default_language) + self.has_template_for_language(template_name, &self.default_language.clone()) } - /// Check if a template exists for a specific language pub fn has_template_for_language(&self, template_name: &str, language: &str) -> bool { - let full_template_name = format!("{}_{}", language, template_name); - self.handlebars.has_template(&full_template_name) + self.tera + .get_template(&format!("{}_{}", language, template_name)) + .is_ok() || self - .handlebars - .has_template(&format!("{}_{}", self.default_language, template_name)) - || self.handlebars.has_template(template_name) // legacy support + .tera + .get_template(&format!("{}_{}", self.default_language, template_name)) + .is_ok() + || self.tera.get_template(template_name).is_ok() } - /// Get all available template names (without language prefixes) pub fn get_template_names(&self) -> Vec { let mut templates = std::collections::HashSet::new(); - - for template_name in self.handlebars.get_templates().keys() { - // Remove language prefix if present - if let Some(underscore_pos) = template_name.find('_') { - let without_lang = &template_name[underscore_pos + 1..]; - templates.insert(without_lang.to_string()); + for name in self.tera.get_template_names() { + if let Some(pos) = name.find('_') { + templates.insert(name[pos + 1..].to_string()); } else { - templates.insert(template_name.clone()); + templates.insert(name.to_string()); } } - templates.into_iter().collect() } - /// Get available languages for templates pub fn get_available_languages(&self) -> Vec { let mut languages = std::collections::HashSet::new(); - - for template_name in self.handlebars.get_templates().keys() { - if let Some(underscore_pos) = template_name.find('_') { - let lang = &template_name[..underscore_pos]; - languages.insert(lang.to_string()); + for name in self.tera.get_template_names() { + if let Some(pos) = name.find('_') { + languages.insert(name[..pos].to_string()); } } - languages.into_iter().collect() } - /// Register a new template from string pub fn register_template(&mut self, name: &str, template: &str) -> EmailResult<()> { - self.handlebars - .register_template_string(name, template) - .map_err(|e| { - EmailError::template(&format!("Failed to register template {}: {}", name, e)) - }) + self.raw_templates + .insert(name.to_string(), template.to_string()); + self.tera.add_raw_template(name, template).map_err(|e| { + EmailError::template(&format!("Failed to register template {}: {}", name, e)) + }) } - /// Unregister a template pub fn unregister_template(&mut self, name: &str) { - self.handlebars.unregister_template(name); + self.raw_templates.remove(name); + self.rebuild_tera(); } - /// Clear all templates pub fn clear_templates(&mut self) { - self.handlebars.clear_templates(); + self.raw_templates.clear(); + self.tera = Tera::default(); } - /// Reload templates from directory pub fn reload_templates(&mut self) -> EmailResult<()> { - self.clear_templates(); + self.raw_templates.clear(); + self.tera = Tera::default(); self.load_templates() } -} -/// Helper function to format dates -fn date_format_helper( - h: &Helper, - _: &Handlebars, - _: &Context, - _: &mut RenderContext, - out: &mut dyn Output, -) -> HelperResult { - let param = h.param(0); - let format = h - .param(1) - .and_then(|v| v.value().as_str()) - .unwrap_or("%Y-%m-%d %H:%M:%S UTC"); - - if let Some(param) = param { - if let Some(date_str) = param.value().as_str() { - if let Ok(datetime) = chrono::DateTime::parse_from_rfc3339(date_str) { - let formatted = datetime.format(format); - out.write(&formatted.to_string())?; - } else { - out.write(date_str)?; - } + fn rebuild_tera(&mut self) { + let mut tera = Tera::default(); + for (name, source) in &self.raw_templates { + let _ = tera.add_raw_template(name, source); } + self.tera = tera; } - Ok(()) -} - -/// Helper function to capitalize text -fn capitalize_helper( - h: &Helper, - _: &Handlebars, - _: &Context, - _: &mut RenderContext, - out: &mut dyn Output, -) -> HelperResult { - if let Some(param) = h.param(0) { - if let Some(text) = param.value().as_str() { - let capitalized = text.chars().next().map_or(String::new(), |first| { - first.to_uppercase().collect::() + &text[1..] - }); - out.write(&capitalized)?; - } + fn build_context(data: &HashMap) -> EmailResult { + let value = serde_json::to_value(data).map_err(|e| { + EmailError::template(&format!("Failed to serialize template data: {}", e)) + })?; + Context::from_value(value) + .map_err(|e| EmailError::template(&format!("Failed to build template context: {}", e))) } - - Ok(()) -} - -/// Helper function to truncate text -fn truncate_helper( - h: &Helper, - _: &Handlebars, - _: &Context, - _: &mut RenderContext, - out: &mut dyn Output, -) -> HelperResult { - if let Some(param) = h.param(0) { - if let Some(text) = param.value().as_str() { - let length = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(100) as usize; - - let truncated = if text.len() > length { - format!("{}...", &text[..length]) - } else { - text.to_string() - }; - - out.write(&truncated)?; - } - } - - Ok(()) -} - -/// Helper function to provide default values -fn default_helper( - h: &Helper, - _: &Handlebars, - _: &Context, - _: &mut RenderContext, - out: &mut dyn Output, -) -> HelperResult { - if let Some(param) = h.param(0) { - if !param.value().is_null() { - if let Some(text) = param.value().as_str() { - if !text.is_empty() { - out.write(text)?; - return Ok(()); - } - } - } - } - - if let Some(default) = h.param(1) { - if let Some(text) = default.value().as_str() { - out.write(text)?; - } - } - - Ok(()) -} - -/// Helper function to URL encode text -fn url_encode_helper( - h: &Helper, - _: &Handlebars, - _: &Context, - _: &mut RenderContext, - out: &mut dyn Output, -) -> HelperResult { - if let Some(param) = h.param(0) { - if let Some(text) = param.value().as_str() { - let encoded = urlencoding::encode(text); - out.write(&encoded)?; - } - } - - Ok(()) } #[cfg(test)] @@ -472,10 +359,8 @@ mod tests { let engine = EmailTemplateEngine::new_with_language(temp_dir.path().to_str().unwrap(), "en") .unwrap(); - - // Test language detection let languages = engine.get_available_languages(); - assert!(languages.is_empty()); // No templates loaded in empty dir + assert!(languages.is_empty()); } #[test] @@ -485,21 +370,34 @@ mod tests { EmailTemplateEngine::new_with_language(temp_dir.path().to_str().unwrap(), "en") .unwrap(); - // Register a template manually for testing engine - .register_template("en_test_template", "Hello {{name}}!") + .register_template("en_test_template", "Hello {{ name }}!") .unwrap(); let mut data = HashMap::new(); data.insert("name".to_string(), Value::String("John".to_string())); - // Test rendering with language let result = engine.render_with_language("test_template", &data, "en"); assert!(result.is_ok()); assert_eq!(result.unwrap(), "Hello John!"); - // Test fallback to default language + // Falls back to "en" default let result = engine.render_with_language("test_template", &data, "es"); - assert!(result.is_ok()); // Should fall back to "en" + assert!(result.is_ok()); + } + + #[test] + fn test_unregister_template() { + let temp_dir = TempDir::new().unwrap(); + let mut engine = EmailTemplateEngine::new(temp_dir.path().to_str().unwrap()).unwrap(); + + engine.register_template("test_tpl", "content").unwrap(); + assert!( + engine.has_template_for_language("test_tpl", "en") + || engine.tera.get_template("test_tpl").is_ok() + ); + + engine.unregister_template("test_tpl"); + assert!(!engine.tera.get_template("test_tpl").is_ok()); } } diff --git a/crates/foundation/crates/rustelo_server/src/handlers/email/handlers.rs b/crates/foundation/crates/rustelo_server/src/handlers/email/handlers.rs index 2934b49..bc660d9 100644 --- a/crates/foundation/crates/rustelo_server/src/handlers/email/handlers.rs +++ b/crates/foundation/crates/rustelo_server/src/handlers/email/handlers.rs @@ -183,7 +183,7 @@ pub async fn send_support_form( // Extract request metadata let ip_address = extract_ip_address(&headers, Some(ConnectInfo(addr))); - let user_agent = extract_user_agent(&headers); + let _user_agent = extract_user_agent(&headers); let language = extract_language_from_headers(&headers); // Check rate limiting diff --git a/crates/foundation/crates/rustelo_server/src/handlers/email/mod.rs b/crates/foundation/crates/rustelo_server/src/handlers/email/mod.rs index f7e66e7..a5634e0 100644 --- a/crates/foundation/crates/rustelo_server/src/handlers/email/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/handlers/email/mod.rs @@ -139,7 +139,7 @@ pub fn extract_language_from_headers(headers: &HeaderMap) -> String { /// Extract language from user profile or session /// This would typically integrate with your user authentication system #[allow(dead_code)] -pub fn extract_language_from_user(user_id: Option<&str>) -> String { +pub fn extract_language_from_user(_user_id: Option<&str>) -> String { // TODO: Implement user profile language detection // This would query the user's language preference from the database // For now, return default language @@ -164,7 +164,7 @@ pub fn determine_email_language(headers: &HeaderMap, user_id: Option<&str>) -> S /// Basic rate limiting check (simple implementation) /// In production, you might want to use a more sophisticated rate limiter -pub fn check_rate_limit(ip: &str) -> bool { +pub fn check_rate_limit(_ip: &str) -> bool { // TODO: Implement proper rate limiting // For now, just return true (no rate limiting) true diff --git a/crates/foundation/crates/rustelo_server/src/handlers/mod.rs b/crates/foundation/crates/rustelo_server/src/handlers/mod.rs index 80c480f..2240760 100644 --- a/crates/foundation/crates/rustelo_server/src/handlers/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/handlers/mod.rs @@ -8,7 +8,7 @@ pub mod template; #[allow(dead_code)] pub async fn test_handler() -> Json { - leptos::logging::log!("Test endpoint working!"); + tracing::info!("Test endpoint working!"); Json(json!({ "status": "ok", "message": "Test endpoint working!" diff --git a/crates/foundation/crates/rustelo_server/src/health.rs b/crates/foundation/crates/rustelo_server/src/health.rs index 20bc44a..7146aba 100644 --- a/crates/foundation/crates/rustelo_server/src/health.rs +++ b/crates/foundation/crates/rustelo_server/src/health.rs @@ -15,6 +15,8 @@ use serde::{Deserialize, Serialize}; #[cfg(any(feature = "auth", feature = "content-db"))] use sqlx::PgPool; use tracing::debug; +#[cfg(any(feature = "auth", feature = "content-db"))] +use tracing::error; // Service imports removed as they're not directly used in health checks use crate::app_state::AppState; diff --git a/crates/foundation/crates/rustelo_server/src/htmx_assets.rs b/crates/foundation/crates/rustelo_server/src/htmx_assets.rs new file mode 100644 index 0000000..0cc1612 --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/htmx_assets.rs @@ -0,0 +1,226 @@ +//! Startup verification of vendored htmx assets against `htmx.lock.toml`. +//! +//! Called once during server boot when the workspace rendering profile is +//! `htmx-ssr` (or any route resolves to it). On mismatch, a `tracing::warn!` +//! is emitted per file; the server keeps running so the operator can roll +//! forward without an outage. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use sha2::{Digest, Sha384}; + +/// Path within the project root where the lockfile is expected. +pub const LOCKFILE_RELATIVE_PATH: &str = "templates/shared/htmx/htmx.lock.toml"; + +/// Schema of `templates/shared/htmx/htmx.lock.toml`. +#[derive(Debug, Deserialize)] +pub struct HtmxLockfile { + pub runtime: HtmxFileEntry, + #[serde(default)] + pub extensions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct HtmxFileEntry { + #[allow(dead_code)] + pub package: Option, + pub version: String, + #[allow(dead_code)] + pub source_url: String, + pub file: String, + pub sha384: String, + pub size_bytes: u64, +} + +#[derive(Debug, Deserialize)] +pub struct HtmxExtensionEntry { + pub name: String, + #[allow(dead_code)] + pub attribute: String, + pub version: String, + #[allow(dead_code)] + pub source_url: String, + pub file: String, + pub sha384: String, + pub size_bytes: u64, +} + +/// One verification finding per file. +#[derive(Debug, PartialEq)] +pub struct VerificationFinding { + pub file: String, + pub kind: FindingKind, +} + +#[derive(Debug, PartialEq)] +pub enum FindingKind { + Missing, + SizeMismatch { expected: u64, actual: u64 }, + HashMismatch { expected: String, actual: String }, +} + +impl std::fmt::Display for VerificationFinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.kind { + FindingKind::Missing => write!(f, "{}: missing on disk", self.file), + FindingKind::SizeMismatch { expected, actual } => write!( + f, + "{}: size mismatch (expected {} bytes, got {})", + self.file, expected, actual + ), + FindingKind::HashMismatch { expected, actual } => write!( + f, + "{}: sha384 mismatch (expected {}, got {})", + self.file, expected, actual + ), + } + } +} + +/// Load the lockfile from disk and verify every entry it references. +/// +/// Returns an empty vec when every file matches its recorded size and hash. +/// Per-file findings are logged via `tracing::warn!` regardless of the +/// caller's handling. +pub fn verify_against_lockfile(htmx_dir: &Path) -> std::io::Result> { + let lock_path = htmx_dir.join("htmx.lock.toml"); + let content = std::fs::read_to_string(&lock_path)?; + let lock: HtmxLockfile = toml::from_str(&content) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + + let mut findings = Vec::new(); + + check_entry( + &htmx_dir.join(&lock.runtime.file), + &lock.runtime.file, + lock.runtime.size_bytes, + &lock.runtime.sha384, + &mut findings, + ); + tracing::info!( + version = %lock.runtime.version, + "htmx runtime present" + ); + + for ext in &lock.extensions { + check_entry( + &htmx_dir.join(&ext.file), + &ext.file, + ext.size_bytes, + &ext.sha384, + &mut findings, + ); + tracing::debug!( + extension = %ext.name, + version = %ext.version, + "htmx extension present" + ); + } + + for finding in &findings { + tracing::warn!(file = %finding.file, finding = %finding, "htmx asset verification failed"); + } + + Ok(findings) +} + +/// Convenience wrapper that runs verification rooted at the project root. +pub fn verify_default() -> std::io::Result> { + let dir = PathBuf::from(crate::utils::resolve_from_root("templates/shared/htmx")); + verify_against_lockfile(&dir) +} + +fn check_entry( + path: &Path, + declared_name: &str, + expected_size: u64, + expected_hash: &str, + findings: &mut Vec, +) { + let metadata = match std::fs::metadata(path) { + Ok(m) => m, + Err(_) => { + findings.push(VerificationFinding { + file: declared_name.to_owned(), + kind: FindingKind::Missing, + }); + return; + } + }; + let actual_size = metadata.len(); + if actual_size != expected_size { + findings.push(VerificationFinding { + file: declared_name.to_owned(), + kind: FindingKind::SizeMismatch { + expected: expected_size, + actual: actual_size, + }, + }); + return; + } + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(_) => { + findings.push(VerificationFinding { + file: declared_name.to_owned(), + kind: FindingKind::Missing, + }); + return; + } + }; + let mut hasher = Sha384::new(); + hasher.update(&bytes); + let actual_hash = hex::encode(hasher.finalize()); + if actual_hash != expected_hash { + findings.push(VerificationFinding { + file: declared_name.to_owned(), + kind: FindingKind::HashMismatch { + expected: expected_hash.to_owned(), + actual: actual_hash, + }, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verifies_real_lockfile_against_vendored_assets() { + // Resolves to repo's templates/shared/htmx which was vendored under L1/L2. + let dir = PathBuf::from(crate::utils::resolve_from_root("templates/shared/htmx")); + if !dir.join("htmx.lock.toml").exists() { + // Skip in environments where assets haven't been vendored yet. + return; + } + let findings = verify_against_lockfile(&dir).expect("verify"); + assert!( + findings.is_empty(), + "expected vendored assets to match lockfile, got: {:?}", + findings + ); + } + + #[test] + fn missing_file_is_reported() { + let tmp = tempfile::tempdir().expect("create tempdir"); + std::fs::write( + tmp.path().join("htmx.lock.toml"), + r#" +[runtime] +package = "htmx.org" +version = "0.0.0" +source_url = "" +file = "absent.js" +sha384 = "deadbeef" +size_bytes = 0 +"#, + ) + .expect("write lockfile"); + let findings = verify_against_lockfile(tmp.path()).expect("verify"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].kind, FindingKind::Missing); + } +} diff --git a/crates/foundation/crates/rustelo_server/src/lib.rs b/crates/foundation/crates/rustelo_server/src/lib.rs index 05788c7..a72a850 100644 --- a/crates/foundation/crates/rustelo_server/src/lib.rs +++ b/crates/foundation/crates/rustelo_server/src/lib.rs @@ -168,13 +168,21 @@ pub mod api; pub mod app_state; pub mod config; +pub mod htmx_assets; +#[cfg(feature = "leptos-hydration")] pub mod routing; pub mod run; +#[cfg(feature = "leptos-hydration")] pub mod shell; +pub mod templating; pub mod utils; pub use app_state::AppState; pub use run::run_main; +/// Convenience re-export of the rendering profile type and registry so +/// implementations can apply workspace defaults without an extra dependency +/// on `rustelo_core_types` (ADR-006). +pub use rustelo_core_types::rendering; // Core modules (always available) mod content_loader; @@ -187,13 +195,16 @@ pub mod startup; #[cfg(feature = "auth")] pub mod auth; -#[cfg(any(feature = "content-db", feature = "content-static"))] +// content module is always compiled — the websocket submodule provides RBAC +// policy sync regardless of storage features. Internal submodules (api, +// repository, service, …) remain individually feature-gated inside +// content/mod.rs. pub mod content; #[cfg(feature = "crypto")] pub mod crypto; -#[cfg(any(feature = "auth", feature = "content-db"))] +#[cfg(any(feature = "auth", feature = "content-db", feature = "post-engagement"))] pub mod database; #[cfg(feature = "email")] @@ -205,13 +216,13 @@ pub mod examples; #[cfg(feature = "metrics")] pub mod metrics; -#[cfg(any(feature = "auth", feature = "content-db"))] +#[cfg(any(feature = "auth", feature = "content-db", feature = "post-engagement"))] pub mod migrations; -// RBAC module temporarilyдисabled due to compilation issues -// TODO: Fix RBAC implementation errors -// #[cfg(feature = "rbac")] -// pub mod rbac; +pub mod rbac; + +#[cfg(feature = "nats-admin")] +pub mod nats; #[cfg(feature = "content-db")] pub mod template; diff --git a/crates/foundation/crates/rustelo_server/src/migrations.rs b/crates/foundation/crates/rustelo_server/src/migrations.rs index 2be81c0..ade626f 100644 --- a/crates/foundation/crates/rustelo_server/src/migrations.rs +++ b/crates/foundation/crates/rustelo_server/src/migrations.rs @@ -34,7 +34,7 @@ impl MigrationRunner { /// Scans the migrations directory and loads SQL files in order pub fn new() -> Self { // Initialize utils to ensure project root is set - rustelo_utils::init(); + utils::init(); let migrations = Self::load_migrations_from_directory().unwrap_or_else(|e| { error!("Failed to load migrations: {}", e); @@ -46,13 +46,19 @@ impl MigrationRunner { /// Scan migrations directory and load SQL files in order fn load_migrations_from_directory() -> Result, Box> { - let migrations_dir = rustelo_utils::paths::migrations_dir(); + let migrations_dir = utils::paths::migrations_dir(); if !migrations_dir.exists() { - warn!( - "Migrations directory not found: {}", - migrations_dir.display() - ); + let short = std::env::current_dir() + .ok() + .and_then(|cwd| { + migrations_dir + .strip_prefix(&cwd) + .ok() + .map(|p| p.to_path_buf()) + }) + .unwrap_or_else(|| migrations_dir.clone()); + warn!("Migrations directory not found: {}", short.display()); return Ok(vec![]); } @@ -151,7 +157,7 @@ impl MigrationRunner { let mut tx = pool.begin().await?; // Apply the migration - sqlx::query(&migration.postgres_sql) + sqlx::query(sqlx::AssertSqlSafe(migration.postgres_sql.as_str())) .execute(&mut *tx) .await .map_err(|e| { @@ -212,7 +218,7 @@ impl MigrationRunner { let mut tx = pool.begin().await?; // Apply the migration - sqlx::query(&migration.sqlite_sql) + sqlx::query(sqlx::AssertSqlSafe(migration.sqlite_sql.as_str())) .execute(&mut *tx) .await .map_err(|e| { @@ -372,9 +378,12 @@ impl MigrationRunner { // Drop all tables for row in rows { let table_name: String = row.get("name"); - sqlx::query(&format!("DROP TABLE IF EXISTS {};", table_name)) - .execute(pool) - .await?; + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP TABLE IF EXISTS {};", + table_name + ))) + .execute(pool) + .await?; } // Run all migrations fresh diff --git a/crates/foundation/crates/rustelo_server/src/nats/client.rs b/crates/foundation/crates/rustelo_server/src/nats/client.rs new file mode 100644 index 0000000..b8835bc --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/nats/client.rs @@ -0,0 +1,296 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_nats::{ConnectOptions, HeaderMap}; +use platform_nats::{EventStream, NatsConfig}; +use tracing::{error, info, warn}; + +use super::subjects::SubjectBuilder; +use crate::config::NatsAdminConnectionConfig; + +/// NATS admin client composing `platform_nats::EventStream` (JetStream + NKey +/// auth) with a raw `async_nats::Client` for request-reply operations. +/// +/// # Architecture split +/// +/// | Use case | Transport | +/// |---|---| +/// | Pipeline pub/consume | `EventStream` (JetStream, durable, NKey signed) | +/// | Admin health / logs | Raw client (core NATS request-reply) | +/// | Subject namespacing | `SubjectBuilder` (prefix.env.leaf) | +/// +/// Cheaply cloneable: both `Arc` and `async_nats::Client` are +/// `Arc`-backed. +#[derive(Clone)] +pub struct NatsAdminClient { + /// JetStream stream for pipeline event pub/consume with optional NKey auth. + /// Wrapped in `Arc` because `EventStream` is not `Clone` (holds a pull + /// consumer). + event_stream: Option>, + /// Raw NATS client for request-reply (health, logs) and push subscriptions. + raw_client: async_nats::Client, + subjects: SubjectBuilder, + subject_map: std::collections::HashMap, +} + +impl NatsAdminClient { + /// Connect to the NATS server. + /// + /// Establishes a raw connection (always) and, if `cfg.jetstream.enabled`, + /// also connects `EventStream` which creates/ensures the configured stream + /// and pull consumers. + pub async fn connect(cfg: &NatsAdminConnectionConfig) -> Result { + let timeout = Duration::from_secs(cfg.connection_timeout); + let reconnect_delay = Duration::from_secs(cfg.reconnect_delay); + + let options = build_connect_options( + &cfg.credentials_file, + timeout, + reconnect_delay, + cfg.max_reconnects, + ) + .await?; + + let raw_client = async_nats::connect_with_options(&cfg.url, options) + .await + .map_err(|e| ConnectError::Io(e.to_string()))?; + + info!("nats-admin: connected to {}", cfg.url); + + let event_stream = if cfg.jetstream.enabled { + let nkey_seed = cfg + .nkey_seed + .as_deref() + .filter(|s| !s.is_empty()) + .map(str::to_owned); + + let js_cfg = cfg.jetstream.pipeline_subjects(&cfg.namespace); + let nats_cfg = NatsConfig { + url: cfg.url.clone(), + nkey_seed, + stream_name: cfg.jetstream.stream_name.clone(), + consumer_name: cfg + .jetstream + .consumers + .first() + .map(|c| c.name.clone()) + .unwrap_or_else(|| "rustelo-pipelines".to_string()), + subjects: js_cfg, + require_signed_messages: cfg.require_signed_messages, + trusted_nkeys: cfg.trusted_nkeys.clone(), + }; + + match EventStream::connect(&nats_cfg).await { + Ok(es) => { + info!( + stream = %cfg.jetstream.stream_name, + "nats-admin: JetStream stream ready" + ); + Some(Arc::new(es)) + } + Err(e) => { + warn!(error = %e, "nats-admin: JetStream init failed, falling back to core NATS"); + None + } + } + } else { + None + }; + + Ok(Self { + event_stream, + raw_client, + subjects: SubjectBuilder::new(&cfg.namespace), + subject_map: cfg.subjects.clone(), + }) + } + + /// Publish a message to a named subject leaf. + /// + /// When JetStream is enabled, publishes via `EventStream` (NKey-signed if + /// configured). Falls back to raw core NATS publish when JetStream is + /// unavailable. + pub async fn publish( + &self, + subject_key: &str, + payload: impl Into>, + ) -> Result<(), PublishError> { + let subject = self.resolve(subject_key)?; + let bytes = bytes::Bytes::from(payload.into()); + + if let Some(es) = &self.event_stream { + es.publish(&subject, bytes) + .await + .map_err(|e| PublishError::EventStream(e.to_string())) + } else { + self.raw_client + .publish(subject, bytes) + .await + .map_err(PublishError::Nats) + } + } + + /// Publish with explicit NATS headers (e.g. for structured metadata). + /// + /// Always uses the raw client — header customisation is not available + /// through `EventStream` (which manages its own NKey headers). + pub async fn publish_with_headers( + &self, + subject_key: &str, + headers: HeaderMap, + payload: impl Into>, + ) -> Result<(), PublishError> { + let subject = self.resolve(subject_key)?; + self.raw_client + .publish_with_headers(subject, headers, payload.into().into()) + .await + .map_err(PublishError::Nats) + } + + /// Send a request and await a single reply (request-reply pattern). + /// + /// Used for admin health checks and log streaming. Always uses the raw + /// client — `platform_nats::EventStream` does not support request-reply. + pub async fn request( + &self, + subject_key: &str, + payload: impl Into>, + ) -> Result { + let subject = self.resolve(subject_key)?; + self.raw_client + .request(subject, payload.into().into()) + .await + .map_err(RequestError::Nats) + } + + /// Subscribe to a named subject leaf for incoming admin commands. + /// + /// Returns a push-based `async_nats::Subscriber`. Used by admin health + /// and log subscribers which need request-reply semantics. + pub async fn subscribe( + &self, + subject_key: &str, + ) -> Result { + let subject = self.resolve(subject_key)?; + self.raw_client + .subscribe(subject) + .await + .map_err(SubscribeError::Nats) + } + + /// Raw `async_nats::Client` for advanced operations (direct pub, req-reply + /// to non-mapped subjects, JetStream via `jetstream::new(client)`). + pub fn inner(&self) -> &async_nats::Client { + &self.raw_client + } + + /// Access the JetStream `EventStream` when enabled. + /// + /// Pipeline subscribers use this to pull from the durable consumer. + pub fn event_stream(&self) -> Option> { + self.event_stream.clone() + } + + pub fn subjects(&self) -> &SubjectBuilder { + &self.subjects + } + + fn resolve(&self, key: &str) -> Result { + self.subjects + .build_from_map(&self.subject_map, key) + .ok_or_else(|| UnknownSubject(key.to_string())) + } +} + +async fn build_connect_options( + credentials_file: &str, + timeout: Duration, + reconnect_delay: Duration, + max_reconnects: usize, +) -> Result { + let base = if credentials_file.is_empty() || credentials_file.starts_with("${") { + warn!("nats-admin: no credentials_file configured, connecting without auth"); + ConnectOptions::new() + } else { + ConnectOptions::with_credentials_file(std::path::PathBuf::from(credentials_file)) + .await + .map_err(|e| { + error!("nats-admin: credentials load failed from '{credentials_file}': {e}"); + ConnectError::Credentials(e.to_string()) + })? + }; + + Ok(base + .connection_timeout(timeout) + .max_reconnects(max_reconnects) + .reconnect_delay_callback(move |_| reconnect_delay) + .event_callback(|event| async move { + match event { + async_nats::Event::Connected => info!("nats-admin: connected"), + async_nats::Event::Disconnected => warn!("nats-admin: disconnected"), + async_nats::Event::Draining => warn!("nats-admin: draining"), + async_nats::Event::Closed => warn!("nats-admin: connection closed"), + async_nats::Event::LameDuckMode => warn!("nats-admin: server lame-duck mode"), + async_nats::Event::SlowConsumer(n) => { + warn!("nats-admin: slow consumer ({n} dropped)") + } + async_nats::Event::ServerError(e) => error!("nats-admin: server error: {e}"), + async_nats::Event::ClientError(e) => error!("nats-admin: client error: {e}"), + } + })) +} + +#[derive(Debug)] +struct UnknownSubject(String); + +#[derive(Debug, thiserror::Error)] +pub enum ConnectError { + #[error("failed to load NATS credentials: {0}")] + Credentials(String), + #[error("NATS connection error: {0}")] + Io(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum PublishError { + #[error("unknown subject key '{0}' — not in nats_admin.subjects config")] + UnknownSubject(String), + #[error("NATS publish error: {0}")] + Nats(#[from] async_nats::PublishError), + #[error("JetStream publish error: {0}")] + EventStream(String), +} + +impl From for PublishError { + fn from(e: UnknownSubject) -> Self { + Self::UnknownSubject(e.0) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RequestError { + #[error("unknown subject key '{0}' — not in nats_admin.subjects config")] + UnknownSubject(String), + #[error("NATS request error: {0}")] + Nats(#[from] async_nats::RequestError), +} + +impl From for RequestError { + fn from(e: UnknownSubject) -> Self { + Self::UnknownSubject(e.0) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SubscribeError { + #[error("unknown subject key '{0}' — not in nats_admin.subjects config")] + UnknownSubject(String), + #[error("NATS subscribe error: {0}")] + Nats(#[from] async_nats::SubscribeError), +} + +impl From for SubscribeError { + fn from(e: UnknownSubject) -> Self { + Self::UnknownSubject(e.0) + } +} diff --git a/crates/foundation/crates/rustelo_server/src/nats/executor.rs b/crates/foundation/crates/rustelo_server/src/nats/executor.rs new file mode 100644 index 0000000..b199fd5 --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/nats/executor.rs @@ -0,0 +1,205 @@ +//! Pipeline step executor. +//! +//! Takes a [`Pipeline`] and its incoming NATS payload, then runs each step in +//! order. Step failures are logged but do **not** abort subsequent steps — +//! the pipeline always runs to completion. + +use std::sync::Arc; + +use rustelo_core_lib::cache::content::ContentCache; +#[cfg(not(feature = "auth"))] +use tracing::warn; +use tracing::{error, info}; + +use super::pipeline::{MessageTarget, Pipeline, PipelineStep}; +use crate::content::websocket::{ContentNotification, WebSocketServer}; + +/// Shared server resources passed to every step executor. +#[derive(Clone)] +pub struct PipelineContext { + pub ws: Arc, + pub cache: Arc, + #[cfg(feature = "auth")] + pub auth_repo: Arc, + /// Absolute directory from which relative script paths are resolved + /// (typically `$SITE_CONTENT_PATH`). + pub scripts_root: String, +} + +/// Execute all steps for `pipeline` with the given `event_id` and raw +/// `payload`. +/// +/// `event_id` is the trigger identifier: the actual NATS subject received for +/// NATS pipelines, or the CI/CD event name for CI/CD pipelines. +/// +/// Step errors are logged; execution continues with the next step. +pub async fn execute_pipeline( + pipeline: &Pipeline, + event_id: &str, + payload: &[u8], + ctx: &PipelineContext, +) { + info!(pipeline = %pipeline.name, event = %event_id, "pipeline: triggered"); + + for (i, step) in pipeline.steps.iter().enumerate() { + if let Err(e) = execute_step(step, event_id, payload, ctx).await { + error!( + pipeline = %pipeline.name, + step = i, + error = %e, + "pipeline: step failed, continuing" + ); + } + } +} + +async fn execute_step( + step: &PipelineStep, + subject: &str, + payload: &[u8], + ctx: &PipelineContext, +) -> Result<(), Box> { + match step { + // ── Cache invalidation ──────────────────────────────────────────────── + PipelineStep::CacheInvalidate => { + ctx.cache.clear_all(); + info!(event = %subject, "pipeline: content cache flushed"); + Ok(()) + } + + // ── WebSocket broadcast ─────────────────────────────────────────────── + PipelineStep::Broadcast { kind, message } => { + ctx.ws + .broadcast_notification(ContentNotification::ToastBroadcast { + kind: kind.clone(), + message: message.clone(), + }) + .await; + info!(event = %subject, kind = %kind, "pipeline: toast broadcast sent"); + Ok(()) + } + + // ── External script ─────────────────────────────────────────────────── + PipelineStep::Script { path } => { + let resolved = resolve_script_path(path, &ctx.scripts_root); + let payload_str = String::from_utf8_lossy(payload); + + let (program, args): (&str, Vec<&str>) = if resolved.ends_with(".nu") { + ("nu", vec![&resolved]) + } else if resolved.ends_with(".sh") { + ("sh", vec![&resolved]) + } else { + // Treat as a directly-executable binary / script with shebang. + (resolved.as_str(), vec![]) + }; + + let status = tokio::process::Command::new(program) + .args(&args) + // Generic env vars usable by any pipeline type. + .env("PIPELINE_EVENT", subject) + .env("PIPELINE_PAYLOAD", payload_str.as_ref()) + // Legacy aliases kept for backwards compatibility with NATS scripts. + .env("NATS_SUBJECT", subject) + .env("NATS_PAYLOAD", payload_str.as_ref()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await + .map_err(|e| format!("failed to spawn '{}': {}", resolved, e))?; + + if status.success() { + info!(script = %resolved, event = %subject, "pipeline: script completed"); + Ok(()) + } else { + Err(format!("script '{}' exited with {}", resolved, status).into()) + } + } + + // ── Persist to user inbox ───────────────────────────────────────────── + #[cfg(feature = "auth")] + PipelineStep::PersistMessage { + target, + sender_email, + subject_template, + body_template, + } => { + let payload_str = String::from_utf8_lossy(payload); + let payload_json: serde_json::Value = + serde_json::from_slice(payload).unwrap_or(serde_json::Value::Null); + + let user_id = resolve_target(target, &payload_json)?; + let msg_subject = render_template(subject_template, &payload_json); + let msg_body = body_template + .as_deref() + .map(|t| render_template(t, &payload_json)) + .unwrap_or_else(|| payload_str.into_owned()); + + ctx.auth_repo + .insert_message(user_id, sender_email, &msg_subject, &msg_body) + .await + .map_err(|e| format!("insert_message failed: {e}"))?; + + info!( + event = %subject, + user_id = %user_id, + "pipeline: message persisted to user inbox" + ); + Ok(()) + } + + // When auth feature is disabled, PersistMessage is a no-op with a warning. + #[cfg(not(feature = "auth"))] + PipelineStep::PersistMessage { .. } => { + warn!( + event = %subject, + "pipeline: PersistMessage step requires the 'auth' feature — skipping" + ); + Ok(()) + } + } +} + +/// Resolve `path` against `scripts_root` unless it's already absolute. +fn resolve_script_path(path: &str, scripts_root: &str) -> String { + if std::path::Path::new(path).is_absolute() { + path.to_owned() + } else { + format!("{}/{}", scripts_root.trim_end_matches('/'), path) + } +} + +/// Extract the target user UUID from config or JSON payload. +#[cfg(feature = "auth")] +fn resolve_target( + target: &MessageTarget, + payload: &serde_json::Value, +) -> Result> { + let raw = match target { + MessageTarget::UserId { value } => value.clone(), + MessageTarget::PayloadField { field } => payload + .get(field) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("payload missing field '{field}'"))? + .to_owned(), + }; + uuid::Uuid::parse_str(&raw).map_err(|e| format!("invalid user UUID '{raw}': {e}").into()) +} + +/// Minimal `{{field}}` template rendering from a flat JSON object. +/// +/// Only top-level string fields are substituted. Unresolved placeholders are +/// left as-is. +fn render_template(template: &str, payload: &serde_json::Value) -> String { + let Some(obj) = payload.as_object() else { + return template.to_owned(); + }; + + let mut out = template.to_owned(); + for (key, val) in obj { + if let Some(s) = val.as_str() { + out = out.replace(&format!("{{{{{key}}}}}"), s); + } + } + out +} diff --git a/crates/foundation/crates/rustelo_server/src/nats/mod.rs b/crates/foundation/crates/rustelo_server/src/nats/mod.rs new file mode 100644 index 0000000..5dfc45f --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/nats/mod.rs @@ -0,0 +1,34 @@ +//! NATS admin integration — operational bus for the supervisor toolkit. +//! +//! Enabled by the `nats-admin` cargo feature. +//! +//! # Architecture +//! +//! The web server connects **outbound** to the NATS hub (leaf-node topology). +//! No inbound ports are required in k8s. The supervisor toolkit publishes +//! admin commands; the server subscribes and responds. +//! +//! Full subject path: `{namespace.prefix}.{namespace.env}.{leaf}` +//! e.g. `evol.website.prod.admin.logs` +//! +//! # Usage +//! +//! ```rust,ignore +//! use rustelo_server::nats::NatsAdminClient; +//! +//! let client = NatsAdminClient::connect(&config.external_services.nats_admin.unwrap()).await?; +//! client.publish("health", b"ping").await?; +//! ``` + +pub mod client; +pub mod executor; +pub mod pipeline; +pub mod subjects; + +#[cfg(feature = "pipeline-watcher")] +pub mod watcher; + +pub use client::{NatsAdminClient, PublishError, RequestError, SubscribeError}; +pub use executor::PipelineContext; +pub use pipeline::{load_pipeline_config, Pipeline, PipelineConfig, PipelineTrigger}; +pub use subjects::SubjectBuilder; diff --git a/crates/foundation/crates/rustelo_server/src/nats/pipeline.rs b/crates/foundation/crates/rustelo_server/src/nats/pipeline.rs new file mode 100644 index 0000000..50b3abb --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/nats/pipeline.rs @@ -0,0 +1,187 @@ +//! Config-driven event pipeline. +//! +//! Loaded from `$NATS_PIPELINES_CONFIG` (or +//! `$SITE_CONTENT_PATH/config/pipelines.ncl` as default). +//! +//! Each `[[pipelines]]` entry maps a trigger to an ordered sequence of steps. +//! The `pipeline_type` field selects the trigger mechanism: +//! +//! | `pipeline_type` | Trigger source | +//! |-----------------|------------------------------------------------| +//! | `nats` | NATS JetStream subject subscription | +//! | `ci_cd` | CI/CD event hook (future: webhook endpoint) | +//! +//! The Rust executor is entirely generic — trigger values and step +//! behaviours come from the config file, not from compiled code. +//! +//! # Example config (`site/config/pipelines/nats.ncl`) +//! +//! ```nickel +//! { +//! name = "content-published", +//! pipeline_type = "nats", +//! subject = "evol.website.prod.content.published", +//! steps = [ +//! { type = "cache_invalidate" }, +//! { type = "broadcast", kind = "info", message = "New content is available" }, +//! { type = "script", path = "scripts/nats/on-content-published.nu" }, +//! ], +//! } +//! ``` + +use serde::Deserialize; + +/// Root record of `pipelines.ncl` — deserialized directly from the NCL +/// export JSON. Field names must match the Nickel record keys. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct PipelineConfig { + #[serde(default)] + pub pipelines: Vec, +} + +/// One pipeline: a named trigger → ordered list of steps. +#[derive(Debug, Clone, Deserialize)] +pub struct Pipeline { + /// Human-readable name used in log messages. + pub name: String, + /// Trigger discriminator and its type-specific fields. + #[serde(flatten)] + pub trigger: PipelineTrigger, + #[serde(default)] + pub steps: Vec, +} + +/// Determines how a pipeline is triggered. +/// +/// Serialised as a flat tagged record: the `pipeline_type` key selects the +/// variant and the remaining keys carry variant-specific data. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "pipeline_type", rename_all = "snake_case")] +pub enum PipelineTrigger { + /// Subscribe to a NATS subject and execute on every incoming message. + Nats { + /// Full NATS subject string (e.g. + /// `evol.website.prod.content.published`). Wildcards (`*`, `>`) + /// are valid per NATS subject syntax. + subject: String, + }, + + /// React to a named CI/CD event (webhook-driven; not yet wired at runtime). + CiCd { + /// Event identifier published by the CI/CD system + /// (e.g. `deploy.completed`, `test.passed`). + event: String, + }, +} + +impl PipelineTrigger { + /// Returns a stable label for logging — the subject for NATS, the event + /// name for CI/CD. + pub fn label(&self) -> &str { + match self { + Self::Nats { subject } => subject, + Self::CiCd { event } => event, + } + } +} + +/// A single action in a pipeline execution chain. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PipelineStep { + /// Flush the in-memory content cache so the next request re-reads from + /// disk / database. + CacheInvalidate, + + /// Broadcast a toast notification to all connected WebSocket clients. + Broadcast { + /// Toast severity: `"info"` | `"success"` | `"warning"` | `"error"`. + kind: String, + /// Message text shown in the toast. + message: String, + }, + + /// Execute an external script, passing the event as environment variables. + /// + /// The executor resolves the interpreter from the file extension + /// (`.nu` → `nu`, `.sh` → `sh`, other → direct exec). + /// + /// The script receives: + /// - `PIPELINE_EVENT` — the trigger identifier (NATS subject or CI/CD + /// event). + /// - `PIPELINE_PAYLOAD` — the raw message body as a UTF-8 string. + Script { + /// Path to the script. Relative paths are resolved against + /// `$SITE_CONTENT_PATH` (or the directory containing the pipeline + /// config file when `$NATS_PIPELINES_CONFIG` is set). + path: String, + }, + + /// Persist the event as a message in a user's inbox (`user_messages` + /// table). Requires the `auth` feature. + PersistMessage { + /// How to determine the target user UUID. + target: MessageTarget, + /// Sender label shown in the inbox (e.g. `"ci@example.com"`). + sender_email: String, + /// Subject line. Supports `{{field}}` template substitution + /// from the JSON payload. + subject_template: String, + /// Body text. Defaults to the raw payload when omitted. + #[serde(default)] + body_template: Option, + }, +} + +/// Determines which user receives a `PersistMessage` step. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MessageTarget { + /// Literal UUID string configured in the pipeline file. + UserId { value: String }, + /// Extract the UUID from a field in the JSON payload + /// (e.g. `field = "user_id"`). + PayloadField { field: String }, +} + +#[derive(Debug, thiserror::Error)] +pub enum PipelineConfigError { + #[error("pipeline config load failed for '{path}': {cause}")] + Load { path: String, cause: String }, + #[error("pipeline config deserialisation failed for '{path}': {cause}")] + Deserialise { path: String, cause: String }, +} + +/// Load a `PipelineConfig` from a concrete NCL file path. +/// +/// Returns `Ok(PipelineConfig::default())` when the file does not exist so +/// the server starts cleanly when no pipeline config has been written yet. +pub fn load_pipeline_config(path: &std::path::Path) -> Result { + let path_str = path.display().to_string(); + + if !path.exists() { + tracing::debug!(path = %path_str, "pipelines: config not found, skipping"); + return Ok(PipelineConfig::default()); + } + + let json = + rustelo_config::format::load_config(path).map_err(|e| PipelineConfigError::Load { + path: path_str.clone(), + cause: e.to_string(), + })?; + + // Substitute ${VAR} tokens (e.g. ${NATS_NAMESPACE}) in the JSON text before + // deserialisation — same mechanism used by Config::load(). + let raw = serde_json::to_string(&json).map_err(|e| PipelineConfigError::Load { + path: path_str.clone(), + cause: e.to_string(), + })?; + let substituted = crate::config::Config::substitute_env_in_string(&raw); + + serde_json::from_str::(&substituted).map_err(|e| { + PipelineConfigError::Deserialise { + path: path_str, + cause: e.to_string(), + } + }) +} diff --git a/crates/foundation/crates/rustelo_server/src/nats/subjects.rs b/crates/foundation/crates/rustelo_server/src/nats/subjects.rs new file mode 100644 index 0000000..5228737 --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/nats/subjects.rs @@ -0,0 +1,78 @@ +use crate::config::NatsAdminNamespaceConfig; + +/// Builds fully-qualified NATS subject paths from a namespace config and leaf +/// subject names. +/// +/// Full subject: `{prefix}.{env}.{leaf}` +/// e.g. `evol.website.prod.admin.logs` +/// +/// The leaf names come from `NatsAdminConnectionConfig::subjects` — an open map +/// so implementations can extend them without touching framework code. +#[derive(Debug, Clone)] +pub struct SubjectBuilder { + prefix: String, + env: String, +} + +impl SubjectBuilder { + pub fn new(namespace: &NatsAdminNamespaceConfig) -> Self { + Self { + prefix: namespace.prefix.clone(), + env: namespace.env.clone(), + } + } + + /// Build a full subject from a leaf name stored in the config subjects map. + /// + /// Returns `None` if the leaf key is not present in the map. + pub fn build_from_map( + &self, + subjects: &std::collections::HashMap, + key: &str, + ) -> Option { + subjects.get(key).map(|leaf| self.build(leaf)) + } + + /// Build a full subject from an explicit leaf string. + pub fn build(&self, leaf: &str) -> String { + format!("{}.{}.{}", self.prefix, self.env, leaf) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn make_builder() -> SubjectBuilder { + SubjectBuilder { + prefix: "evol.website".to_string(), + env: "prod".to_string(), + } + } + + #[test] + fn build_full_subject() { + let b = make_builder(); + assert_eq!(b.build("admin.logs"), "evol.website.prod.admin.logs"); + } + + #[test] + fn build_from_map_found() { + let b = make_builder(); + let mut m = HashMap::new(); + m.insert("logs".to_string(), "admin.logs".to_string()); + assert_eq!( + b.build_from_map(&m, "logs"), + Some("evol.website.prod.admin.logs".to_string()) + ); + } + + #[test] + fn build_from_map_missing() { + let b = make_builder(); + let m = HashMap::new(); + assert!(b.build_from_map(&m, "nonexistent").is_none()); + } +} diff --git a/crates/foundation/crates/rustelo_server/src/nats/watcher.rs b/crates/foundation/crates/rustelo_server/src/nats/watcher.rs new file mode 100644 index 0000000..55bb95f --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/nats/watcher.rs @@ -0,0 +1,109 @@ +//! Hot-reload watcher for the pipeline config directory. +//! +//! Enabled by the `pipeline-watcher` cargo feature. +//! +//! Watches `site/pipelines/` (or the parent directory of the configured NCL +//! path) recursively. Any `Modify` event triggers a reload of the root NCL +//! file; on success the current subscriber tasks are aborted and a fresh set +//! is spawned from the new config. +//! +//! # Pattern +//! +//! ```text +//! notify::Modify event +//! → debounce (drain channel) +//! → load_pipeline_config(path) +//! Ok → abort old JoinHandles → spawn_pipeline_subscribers → store new handles +//! Err → log error, keep existing subscriptions +//! ``` + +use std::path::PathBuf; +use std::sync::Arc; + +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use tracing::{error, info}; + +use super::executor::PipelineContext; +use super::pipeline::load_pipeline_config; +use crate::nats::NatsAdminClient; +use crate::startup::nats::spawn_pipeline_subscribers; + +/// Watch the pipeline config directory for modifications and hot-reload. +/// +/// `path` is the root NCL file (e.g. `site/pipelines/index.ncl`). The +/// parent directory is watched recursively so edits to `nats.ncl`, +/// `ci_cd.ncl`, or any imported contract file trigger a reload. +/// +/// This future runs indefinitely. The caller should `tokio::spawn` it. +pub async fn watch_pipeline_config( + path: PathBuf, + client: NatsAdminClient, + ctx: Arc, +) -> notify::Result<()> { + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(8); + + let mut watcher = RecommendedWatcher::new( + move |res: notify::Result| { + if let Ok(ev) = res { + if matches!(ev.kind, EventKind::Modify(_)) { + let _ = tx.blocking_send(()); + } + } + }, + Config::default(), + )?; + + // Watch the containing directory recursively — index.ncl imports nats.ncl + // and ci_cd.ncl; any of them changing should trigger a reload. + let watch_dir = path.parent().unwrap_or(path.as_path()); + watcher.watch(watch_dir, RecursiveMode::Recursive)?; + + info!( + path = %path.display(), + watch = %watch_dir.display(), + "pipeline watcher started" + ); + + // Initial load — spawn subscribers before entering the event loop. + let mut handles = { + match load_pipeline_config(&path) { + Ok(cfg) => spawn_pipeline_subscribers(client.clone(), cfg.pipelines, Arc::clone(&ctx)), + Err(e) => { + error!(path = %path.display(), error = %e, "pipeline initial load failed"); + Vec::new() + } + } + }; + + while rx.recv().await.is_some() { + // Debounce: drain any rapid follow-up events before processing. + while rx.try_recv().is_ok() {} + + match load_pipeline_config(&path) { + Ok(cfg) => { + let count = cfg.pipelines.len(); + // Abort all current subscriber tasks; they exit at their next + // .await point (next NATS message or subscription poll). + for h in handles.drain(..) { + h.abort(); + } + info!( + path = %path.display(), + pipelines = count, + "pipelines reloaded — subscriptions restarted" + ); + handles = + spawn_pipeline_subscribers(client.clone(), cfg.pipelines, Arc::clone(&ctx)); + } + Err(e) => { + error!( + path = %path.display(), + error = %e, + "pipeline reload failed — keeping existing subscriptions" + ); + } + } + } + + Ok(()) +} diff --git a/crates/foundation/crates/rustelo_server/src/rbac/conditional.rs b/crates/foundation/crates/rustelo_server/src/rbac/conditional.rs index b02e3a7..2b7bb2d 100644 --- a/crates/foundation/crates/rustelo_server/src/rbac/conditional.rs +++ b/crates/foundation/crates/rustelo_server/src/rbac/conditional.rs @@ -1,186 +1,86 @@ -use crate::database::DatabasePool; -use anyhow::Result; -use axum::Router; use std::sync::Arc; -#[cfg(feature = "rbac")] -use super::{ - config::RBACConfigLoader, middleware::rbac_middleware, - repository::RBACRepository, service::RBACService, -}; -use crate::config::features::FeatureConfig; +use anyhow::Result; +use axum::Router; -/// Conditional RBAC service that handles optional RBAC functionality +use crate::config::features::FeatureConfig; +use crate::database::DatabasePool; + +/// Conditional RBAC service — feature flag bridge for optional RBAC +/// functionality. +/// +/// Database-backed `RBACService`/`RBACRepository` are intentionally excluded +/// from compilation (their modules have unresolved interface issues). This +/// struct retains the `FeatureConfig` gate so call-sites don't change when the +/// DB layer is restored. #[derive(Clone)] pub struct ConditionalRBACService { - #[cfg(feature = "rbac")] - pub rbac_service: Option>, - #[cfg(feature = "rbac")] - pub rbac_repository: Option>, pub feature_config: Arc, } impl ConditionalRBACService { - /// Initialize RBAC service based on feature configuration #[allow(dead_code)] pub async fn new( - database_pool: &DatabasePool, + _database_pool: &DatabasePool, feature_config: Arc, - rbac_config_path: Option<&str>, + _rbac_config_path: Option<&str>, ) -> Result { - #[cfg(feature = "rbac")] - let (rbac_service, rbac_repository) = if feature_config.is_rbac_enabled() { - println!("🔐 Initializing RBAC system..."); - - // Initialize RBAC repository - let rbac_repository = Arc::new(RBACRepository::from_database_pool(database_pool)); - - // Initialize RBAC service - let rbac_service = Arc::new(RBACService::new(rbac_repository.clone())); - - // Load configuration if TOML config is enabled - if feature_config.is_rbac_feature_enabled("toml_config") { - if let Some(config_path) = rbac_config_path { - let config_loader = RBACConfigLoader::new(config_path); - - // Create default config if it doesn't exist - if !config_loader.config_exists() { - println!("📝 Creating default RBAC configuration..."); - config_loader.create_default_config().await?; - } - - // Load and save config to database - let rbac_config = config_loader.load_from_file().await?; - rbac_service - .save_rbac_config("default", &rbac_config, Some("Feature initialization")) - .await?; - - println!( - "✅ RBAC configuration loaded with {} rules", - rbac_config.rules.len() - ); - } else { - println!("⚠️ RBAC TOML config enabled but no config path provided"); - } - } - - println!("✅ RBAC system initialized successfully"); - (Some(rbac_service), Some(rbac_repository)) - } else { - println!("ℹ️ RBAC system disabled - using basic role-based authentication"); - (None, None) - }; - - #[cfg(not(feature = "rbac"))] - let (rbac_service, rbac_repository): (Option>, Option>) = { - println!("ℹ️ RBAC system disabled - feature not compiled"); - (None, None) - }; - - #[cfg(feature = "rbac")] - let result = Self { - rbac_service, - rbac_repository, - feature_config, - }; - - #[cfg(not(feature = "rbac"))] - let result = Self { feature_config }; - - Ok(result) + Ok(Self { feature_config }) } - /// Check if RBAC is enabled pub fn is_enabled(&self) -> bool { self.feature_config.is_rbac_enabled() } - /// Check if a specific RBAC feature is enabled pub fn is_feature_enabled(&self, feature: &str) -> bool { self.feature_config.is_rbac_feature_enabled(feature) } - /// Get RBAC service (if enabled) - /// Get RBAC service (if enabled) - no-op when rbac feature is disabled - #[cfg(feature = "rbac")] - #[allow(dead_code)] - pub fn service(&self) -> Option<&Arc> { - self.rbac_service.as_ref() - } - - /// Get RBAC repository (if enabled) - no-op when rbac feature is disabled - #[cfg(feature = "rbac")] - #[allow(dead_code)] - pub fn repository(&self) -> Option<&Arc> { - self.rbac_repository.as_ref() - } - - /// Get RBAC service (if enabled) - no-op when rbac feature is disabled - #[cfg(not(feature = "rbac"))] + /// Returns `None` — DB-backed RBAC service is not compiled. #[allow(dead_code)] pub fn service(&self) -> Option<&Arc<()>> { None } - /// Get RBAC repository (if enabled) - no-op when rbac feature is disabled - #[cfg(not(feature = "rbac"))] + /// Returns `None` — DB-backed RBAC repository is not compiled. #[allow(dead_code)] pub fn repository(&self) -> Option<&Arc<()>> { None } - /// Apply RBAC middleware conditionally + /// File-based RBAC middleware is applied via `startup::middleware`. This is + /// a no-op. pub fn apply_middleware(&self, router: Router) -> Router where S: Clone + Send + Sync + 'static, { - if self.is_enabled() { - #[cfg(feature = "rbac")] - if let Some(rbac_service) = &self.rbac_service { - println!("🛡️ Applying RBAC middleware"); - return router.layer(middleware::from_fn_with_state( - rbac_service.clone(), - rbac_middleware, - )); - } - #[cfg(not(feature = "rbac"))] - { - println!("⚠️ RBAC enabled but service not available (rbac feature disabled)"); - } - } - - println!("ℹ️ Skipping RBAC middleware (disabled)"); router } - /// Check if database access middleware should be enabled pub fn should_enable_database_access(&self) -> bool { self.is_feature_enabled("database_access") } - /// Check if file access middleware should be enabled pub fn should_enable_file_access(&self) -> bool { self.is_feature_enabled("file_access") } - /// Check if content access should be enabled #[allow(dead_code)] pub fn should_enable_content_access(&self) -> bool { self.is_feature_enabled("content_access") } - /// Check if category access middleware should be enabled pub fn should_enable_category_access(&self) -> bool { self.is_feature_enabled("category_access") } - /// Check if role-based access should be enabled #[allow(dead_code)] pub fn should_enable_role_based_access(&self) -> bool { self.is_feature_enabled("role_based_access") } - /// Check user access with fallback to basic role check + /// Check user access. Falls back to role-based check since DB RBAC is not + /// compiled. #[allow(dead_code)] pub async fn check_user_access( &self, @@ -189,85 +89,29 @@ impl ConditionalRBACService { resource_name: &str, action: &str, ) -> Result { - #[cfg(feature = "rbac")] - if let Some(rbac_service) = &self.rbac_service { - // Use RBAC system - return match resource_type { - "database" => { - rbac_service - .check_database_access(user, resource_name, action) - .await - } - "file" => { - rbac_service - .check_file_access(user, resource_name, action) - .await - } - "content" => { - rbac_service - .check_content_access(user, resource_name, action) - .await - } - _ => { - // Use custom access context - let context = rustelo_core_lib::auth::AccessContext { - user: Some(user.clone()), - resource_type: match resource_type { - "database" => rustelo_core_lib::auth::ResourceType::Database, - "file" => rustelo_core_lib::auth::ResourceType::File, - "content" => rustelo_core_lib::auth::ResourceType::Content, - "api" => rustelo_core_lib::auth::ResourceType::Api, - "directory" => rustelo_core_lib::auth::ResourceType::Directory, - _ => rustelo_core_lib::auth::ResourceType::Custom( - resource_type.to_string(), - ), - }, - resource_name: resource_name.to_string(), - action: action.to_string(), - additional_context: std::collections::HashMap::new(), - }; - rbac_service.check_access(&context).await - } - }; - } - - // Fallback to basic role-based access control Ok(self.check_basic_access(user, resource_type, resource_name, action)) } - /// Basic access control fallback when RBAC is disabled - #[allow(dead_code)] fn check_basic_access( &self, user: &rustelo_core_lib::auth::User, - resource_type: &str, + _resource_type: &str, _resource_name: &str, action: &str, ) -> rustelo_core_lib::auth::AccessResult { use rustelo_core_lib::auth::{AccessResult, Role}; - // Simple role-based checks let has_access = match action { "read" => { - // Users can read most resources user.has_role(&Role::User) || user.has_role(&Role::Admin) || user.has_role(&Role::Moderator) } "write" | "create" | "update" => { - // Only moderators and admins can write user.has_role(&Role::Admin) || user.has_role(&Role::Moderator) } - "delete" => { - // Only admins can delete - user.has_role(&Role::Admin) - } - "admin" => { - // Only admins for admin operations - user.has_role(&Role::Admin) - } + "delete" | "admin" => user.has_role(&Role::Admin), _ => { - // Default to requiring at least user role user.has_role(&Role::User) || user.has_role(&Role::Admin) || user.has_role(&Role::Moderator) @@ -281,39 +125,16 @@ impl ConditionalRBACService { } } - /// Start background tasks if RBAC features require them #[allow(dead_code)] pub async fn start_background_tasks(&self) { if !self.is_enabled() { return; } - - if self.is_feature_enabled("caching") { - #[cfg(feature = "rbac")] - if let Some(rbac_service) = &self.rbac_service { - let _service = rbac_service.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(300)); // 5 minutes - loop { - interval.tick().await; - if let Err(e) = service.cleanup_expired_cache().await { - eprintln!("🧹 Error cleaning up RBAC cache: {}", e); - } else { - println!("🧹 RBAC cache cleanup completed"); - } - } - }); - println!("🚀 Started RBAC cache cleanup background task"); - } - } - if self.is_feature_enabled("audit_logging") { - // Could add audit log rotation or analysis tasks here - println!("🚀 RBAC audit logging is enabled"); + tracing::info!("RBAC audit logging enabled"); } } - /// Get feature status for API responses #[allow(dead_code)] pub fn get_feature_status(&self) -> serde_json::Value { serde_json::json!({ @@ -334,7 +155,6 @@ impl ConditionalRBACService { }) } - /// Create RBAC routes conditionally #[allow(dead_code)] pub fn create_rbac_routes(&self) -> Option> where @@ -344,70 +164,42 @@ impl ConditionalRBACService { return None; } - use axum::{ - routing::{get, post}, - Json, - }; + use axum::{routing::get, Json}; use serde_json::json; let mut router = Router::new(); - // Basic RBAC info endpoint router = router.route( "/api/rbac/status", - get(|| async { - Json(json!({ - "enabled": true, - "message": "RBAC system is active" - })) - }), + get(|| async { Json(json!({ "enabled": true, "message": "RBAC system is active" })) }), ); - // Add category management routes if enabled if self.is_feature_enabled("categories") { - router = router - .route( - "/api/rbac/categories", - get(|| async { - Json(json!({ - "categories": ["admin", "editor", "viewer", "finance", "hr", "it"] - })) - }), - ) - .route( - "/api/rbac/categories", - post(|| async { - Json(json!({ - "message": "Category management endpoint" - })) - }), - ); + router = router.route( + "/api/rbac/categories", + get(|| async { + Json(json!({ + "categories": ["admin", "editor", "viewer", "finance", "hr", "it"] + })) + }), + ); } - // Add tag management routes if enabled if self.is_feature_enabled("tags") { - router = router - .route("/api/rbac/tags", get(|| async { + router = router.route( + "/api/rbac/tags", + get(|| async { Json(json!({ "tags": ["sensitive", "public", "internal", "confidential", "restricted"] })) - })) - .route("/api/rbac/tags", post(|| async { - Json(json!({ - "message": "Tag management endpoint" - })) - })); + }), + ); } - // Add audit routes if enabled if self.is_feature_enabled("audit_logging") { router = router.route( "/api/rbac/audit/:user_id", - get(|| async { - Json(json!({ - "message": "Audit log endpoint" - })) - }), + get(|| async { Json(json!({ "message": "Audit log endpoint" })) }), ); } @@ -415,29 +207,11 @@ impl ConditionalRBACService { } } -/// Helper macro for conditional RBAC middleware application -#[macro_export] -macro_rules! apply_rbac_middleware { - ($router:expr, $rbac:expr, $middleware_fn:expr) => { - if $rbac.is_enabled() { - if let Some(middleware) = $middleware_fn { - $router.layer(axum::middleware::from_fn(middleware)) - } else { - $router - } - } else { - $router - } - }; -} - -/// Helper trait for conditional RBAC application +/// Extension trait for conditional RBAC middleware application on `Router`. pub trait ConditionalRBACExt { - /// Apply RBAC middleware if enabled #[allow(dead_code)] fn apply_rbac_if_enabled(self, rbac: &ConditionalRBACService) -> Self; - /// Apply database access middleware if enabled #[allow(dead_code)] fn require_database_access_if_enabled( self, @@ -446,7 +220,6 @@ pub trait ConditionalRBACExt { action: String, ) -> Self; - /// Apply file access middleware if enabled #[allow(dead_code)] fn require_file_access_if_enabled( self, @@ -455,7 +228,6 @@ pub trait ConditionalRBACExt { action: String, ) -> Self; - /// Apply category access middleware if enabled #[allow(dead_code)] fn require_category_access_if_enabled( self, @@ -468,80 +240,34 @@ impl ConditionalRBACExt for Router where S: Clone + Send + Sync + 'static, { - #[allow(dead_code)] fn apply_rbac_if_enabled(self, rbac: &ConditionalRBACService) -> Self { rbac.apply_middleware(self) } - #[allow(dead_code)] fn require_database_access_if_enabled( self, - rbac: &ConditionalRBACService, - database_name: String, - action: String, + _rbac: &ConditionalRBACService, + _database_name: String, + _action: String, ) -> Self { - if rbac.should_enable_database_access() { - #[cfg(feature = "rbac")] - { - self.layer(middleware::from_fn_with_state( - (database_name, action), - super::rbac_middleware::require_database_access, - )) - } - #[cfg(not(feature = "rbac"))] - { - self - } - } else { - self - } + self } - #[allow(dead_code)] fn require_file_access_if_enabled( self, - rbac: &ConditionalRBACService, - file_path: String, - action: String, + _rbac: &ConditionalRBACService, + _file_path: String, + _action: String, ) -> Self { - if rbac.should_enable_file_access() { - #[cfg(feature = "rbac")] - { - self.layer(middleware::from_fn_with_state( - (file_path, action), - super::rbac_middleware::require_file_access, - )) - } - #[cfg(not(feature = "rbac"))] - { - self - } - } else { - self - } + self } - #[allow(dead_code)] fn require_category_access_if_enabled( self, - rbac: &ConditionalRBACService, - categories: Vec, + _rbac: &ConditionalRBACService, + _categories: Vec, ) -> Self { - if rbac.should_enable_category_access() { - #[cfg(feature = "rbac")] - { - self.layer(middleware::from_fn_with_state( - categories, - super::rbac_middleware::require_category_access, - )) - } - #[cfg(not(feature = "rbac"))] - { - self - } - } else { - self - } + self } } @@ -552,15 +278,11 @@ mod tests { #[tokio::test] async fn test_conditional_rbac_disabled() { - let feature_config = Arc::new(FeatureConfig::default()); // RBAC disabled by default + let feature_config = Arc::new(FeatureConfig::default()); - // Use a dummy connection string for testing let pool = match sqlx::PgPool::connect("postgres://test").await { Ok(pool) => pool, - Err(_) => { - // Skip test if no test database - return; - } + Err(_) => return, // skip if no test DB }; let database_pool = DatabasePool::PostgreSQL(pool); @@ -578,8 +300,6 @@ mod tests { feature_config.enable_rbac(); let feature_config = Arc::new(feature_config); - // This would require a test database setup - // For now, just test the configuration logic assert!(feature_config.is_rbac_enabled()); assert!(feature_config.is_rbac_feature_enabled("database_access")); } @@ -587,16 +307,9 @@ mod tests { #[test] fn test_basic_access_control() { use rustelo_core_lib::auth::{Role, User, UserProfile}; - // use std::collections::HashMap; let feature_config = Arc::new(FeatureConfig::default()); - let rbac = ConditionalRBACService { - #[cfg(feature = "rbac")] - rbac_service: None, - #[cfg(feature = "rbac")] - rbac_repository: None, - feature_config, - }; + let rbac = ConditionalRBACService { feature_config }; let admin_user = User { id: uuid::Uuid::new_v4(), diff --git a/crates/foundation/crates/rustelo_server/src/rbac/config.rs b/crates/foundation/crates/rustelo_server/src/rbac/config.rs index 57fac77..e0e63a1 100644 --- a/crates/foundation/crates/rustelo_server/src/rbac/config.rs +++ b/crates/foundation/crates/rustelo_server/src/rbac/config.rs @@ -1,9 +1,10 @@ -use anyhow::Result; -use rustelo_core_lib::auth::{AccessRule, Permission, RBACConfig, ResourceType, Role}; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::Path; + +use anyhow::Result; +use rustelo_core_lib::auth::{AccessRule, Permission, RBACConfig, ResourceType, Role}; +use serde::{Deserialize, Serialize}; use tokio::fs as async_fs; /// TOML configuration structure for RBAC @@ -426,9 +427,10 @@ impl RBACConfigLoader { #[cfg(test)] mod tests { - use super::*; use tempfile::NamedTempFile; + use super::*; + #[test] fn test_parse_permission() { let loader = RBACConfigLoader::new("test.toml"); diff --git a/crates/foundation/crates/rustelo_server/src/rbac/effect_executor.rs b/crates/foundation/crates/rustelo_server/src/rbac/effect_executor.rs new file mode 100644 index 0000000..65ea760 --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/rbac/effect_executor.rs @@ -0,0 +1,188 @@ +use axum::{ + body::Body, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use super::file_config::PolicyEffect; + +/// Request context used for template interpolation in effect messages. +pub struct EffectContext<'a> { + pub path: &'a str, + pub method: &'a str, + pub user_id: Option<&'a str>, + pub user_email: Option<&'a str>, +} + +/// Execute a single policy effect. +/// +/// Returns `Some(Response)` for **terminant** effects that short-circuit the +/// request chain (Redirect, Response). Returns `None` for **side-effect-only** +/// effects that allow processing to continue (Log, AuditLog, Notify). +pub async fn execute_effect(effect: &PolicyEffect, ctx: &EffectContext<'_>) -> Option { + match effect { + PolicyEffect::Redirect { to } => { + let location = interpolate(to, ctx); + let response = Response::builder() + .status(StatusCode::FOUND) + .header("Location", location) + .body(Body::empty()) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); + Some(response) + } + + PolicyEffect::Log { level, message } => { + let msg = message + .as_deref() + .map(|m| interpolate(m, ctx)) + .unwrap_or_else(|| format!("RBAC: {} {}", ctx.method, ctx.path)); + match level.as_str() { + "debug" => tracing::debug!("{}", msg), + "warn" => tracing::warn!("{}", msg), + "error" => tracing::error!("{}", msg), + _ => tracing::info!("{}", msg), + } + None + } + + PolicyEffect::AuditLog { level, event } => { + let event_name = event.as_deref().unwrap_or("rbac_event"); + let user = ctx.user_id.unwrap_or("anonymous"); + let msg = format!( + "RBAC audit: event={} path={} method={} user={}", + event_name, ctx.path, ctx.method, user + ); + match level.as_str() { + "debug" => { + tracing::debug!(audit = true, event = event_name, user = user, "{}", msg) + } + "warn" => { + tracing::warn!(audit = true, event = event_name, user = user, "{}", msg) + } + "error" => { + tracing::error!(audit = true, event = event_name, user = user, "{}", msg) + } + _ => tracing::info!(audit = true, event = event_name, user = user, "{}", msg), + } + None + } + + PolicyEffect::Notify { channel, message } => { + let msg = message + .as_deref() + .map(|m| interpolate(m, ctx)) + .unwrap_or_else(|| ctx.path.to_string()); + let channel = channel.clone(); + // Non-blocking: fire-and-forget notification. + // Replace with actual channel dispatch when integrations are added. + tokio::spawn(async move { + tracing::info!(channel = %channel, "RBAC notify: {}", msg); + }); + None + } + + PolicyEffect::Response { status, body } => { + let status_code = StatusCode::from_u16(*status).unwrap_or(StatusCode::FORBIDDEN); + let response = if let Some(body) = body { + (status_code, axum::Json(body.clone())).into_response() + } else { + status_code.into_response() + }; + Some(response) + } + } +} + +/// Replace `{request.path}`, `{request.method}`, `{user.id}`, `{user.email}` in +/// `template`. +fn interpolate(template: &str, ctx: &EffectContext<'_>) -> String { + template + .replace("{request.path}", ctx.path) + .replace("{request.method}", ctx.method) + .replace("{user.id}", ctx.user_id.unwrap_or("")) + .replace("{user.email}", ctx.user_email.unwrap_or("")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx<'a>(path: &'a str) -> EffectContext<'a> { + EffectContext { + path, + method: "GET", + user_id: Some("u1"), + user_email: Some("u@test.com"), + } + } + + #[test] + fn interpolate_replaces_all_tokens() { + let result = interpolate( + "{user.email} accessed {request.path} via {request.method}", + &ctx("/admin"), + ); + assert_eq!(result, "u@test.com accessed /admin via GET"); + } + + #[test] + fn interpolate_handles_missing_user() { + let ctx = EffectContext { + path: "/test", + method: "POST", + user_id: None, + user_email: None, + }; + let result = interpolate("{user.id}:{user.email}", &ctx); + assert_eq!(result, ":"); + } + + #[tokio::test] + async fn log_effect_returns_none() { + let effect = PolicyEffect::Log { + level: "info".to_string(), + message: Some("test".to_string()), + }; + let result = execute_effect(&effect, &ctx("/")).await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn audit_log_effect_returns_none() { + let effect = PolicyEffect::AuditLog { + level: "warn".to_string(), + event: Some("access_denied".to_string()), + }; + let result = execute_effect(&effect, &ctx("/protected")).await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn redirect_effect_returns_response() { + let effect = PolicyEffect::Redirect { + to: "/login?return={request.path}".to_string(), + }; + let result = execute_effect(&effect, &ctx("/admin")).await; + assert!(result.is_some()); + let response = result.unwrap(); + assert_eq!(response.status(), StatusCode::FOUND); + let location = response + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap(); + assert_eq!(location, "/login?return=/admin"); + } + + #[tokio::test] + async fn response_effect_returns_403() { + let effect = PolicyEffect::Response { + status: 403, + body: Some(serde_json::json!({"error": "Forbidden"})), + }; + let result = execute_effect(&effect, &ctx("/secret")).await; + assert!(result.is_some()); + assert_eq!(result.unwrap().status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/foundation/crates/rustelo_server/src/rbac/file_config.rs b/crates/foundation/crates/rustelo_server/src/rbac/file_config.rs new file mode 100644 index 0000000..807d2b5 --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/rbac/file_config.rs @@ -0,0 +1,464 @@ +use std::collections::{HashSet, VecDeque}; +use std::path::Path; + +use glob::Pattern; +use serde::Deserialize; + +/// Loaded RBAC policy. Derives `Default` so a missing / unreadable file +/// degrades gracefully. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct FileRbacConfig { + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub user_overrides: Vec, + #[serde(default)] + pub defaults: FileRbacDefaults, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FileRbacGroup { + pub name: String, + #[serde(default)] + pub parent_groups: Vec, + #[serde(default)] + pub permissions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FileRbacUserOverride { + pub id: String, + #[serde(default)] + pub permissions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PolicyRule { + pub resource: ResourceKind, + pub pattern: String, + pub outcome: RbacOutcome, + #[serde(default)] + pub methods: Vec, + #[serde(default)] + pub effects: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ResourceKind { + Endpoint, + Page, + Asset, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum RbacOutcome { + Allow, + Deny, +} + +/// Side-effecting actions executed when a rule matches. +/// `type` is the serde discriminator tag matching Nickel's `type` field. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PolicyEffect { + Redirect { + to: String, + }, + Log { + #[serde(default = "default_level")] + level: String, + message: Option, + }, + AuditLog { + #[serde(default = "default_level")] + level: String, + event: Option, + }, + Notify { + channel: String, + message: Option, + }, + Response { + #[serde(default = "default_403")] + status: u16, + body: Option, + }, +} + +fn default_level() -> String { + "info".to_string() +} +fn default_403() -> u16 { + 403 +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FileRbacDefaults { + #[serde(default)] + pub unauthenticated_allow: Vec, + #[serde(default)] + pub unauthenticated_deny: Vec, + #[serde(default = "deny_outcome")] + pub authenticated_fallback: RbacOutcome, + #[serde(default = "deny_outcome")] + pub unauthenticated_fallback: RbacOutcome, + #[serde(default)] + pub fallback_effects: Vec, +} + +fn deny_outcome() -> RbacOutcome { + RbacOutcome::Deny +} + +impl Default for FileRbacDefaults { + fn default() -> Self { + Self { + unauthenticated_allow: Vec::new(), + unauthenticated_deny: Vec::new(), + authenticated_fallback: RbacOutcome::Deny, + unauthenticated_fallback: RbacOutcome::Deny, + fallback_effects: Vec::new(), + } + } +} + +/// The outcome of a policy evaluation: what the server should do. +pub struct PolicyDecision { + pub outcome: RbacOutcome, + pub effects: Vec, +} + +impl FileRbacConfig { + /// Load policy from an `.ncl` or `.toml` file via `rustelo_config`. + pub fn load(path: &Path) -> anyhow::Result { + let json_value = rustelo_config::format::load_config(path) + .map_err(|e| anyhow::anyhow!("failed to load {}: {}", path.display(), e))?; + serde_json::from_value(json_value) + .map_err(|e| anyhow::anyhow!("failed to deserialize FileRbacConfig: {}", e)) + } + + /// Evaluate the policy for an incoming request. + /// + /// - `user_groups`: group names from the authenticated user's JWT roles, or + /// `None` for unauthenticated requests. + /// - `user_id`: the user's identity (email or sub) used for per-user + /// overrides. + /// - First-match wins within each evaluation tier. + pub fn evaluate( + &self, + user_groups: Option<&[String]>, + user_id: Option<&str>, + kind: ResourceKind, + path: &str, + method: &str, + ) -> PolicyDecision { + match user_groups { + None => self.evaluate_unauthenticated(&kind, path, method), + Some(groups) => self.evaluate_authenticated(groups, user_id, &kind, path, method), + } + } + + fn evaluate_unauthenticated( + &self, + kind: &ResourceKind, + path: &str, + method: &str, + ) -> PolicyDecision { + for rule in &self.defaults.unauthenticated_allow { + if matches_rule(rule, kind, path, method) { + return PolicyDecision { + outcome: rule.outcome.clone(), + effects: rule.effects.clone(), + }; + } + } + for rule in &self.defaults.unauthenticated_deny { + if matches_rule(rule, kind, path, method) { + return PolicyDecision { + outcome: rule.outcome.clone(), + effects: rule.effects.clone(), + }; + } + } + PolicyDecision { + outcome: self.defaults.unauthenticated_fallback.clone(), + effects: self.defaults.fallback_effects.clone(), + } + } + + fn evaluate_authenticated( + &self, + user_groups: &[String], + user_id: Option<&str>, + kind: &ResourceKind, + path: &str, + method: &str, + ) -> PolicyDecision { + // Tier 1: per-user overrides (highest priority) + if let Some(id) = user_id { + if let Some(entry) = self.user_overrides.iter().find(|o| o.id == id) { + for rule in &entry.permissions { + if matches_rule(rule, kind, path, method) { + return PolicyDecision { + outcome: rule.outcome.clone(), + effects: rule.effects.clone(), + }; + } + } + } + } + + // Tier 2: group rules with BFS inheritance expansion + let all_groups = expand_groups(user_groups, &self.groups); + for group_name in &all_groups { + if let Some(group) = self.groups.iter().find(|g| g.name == *group_name) { + for rule in &group.permissions { + if matches_rule(rule, kind, path, method) { + return PolicyDecision { + outcome: rule.outcome.clone(), + effects: rule.effects.clone(), + }; + } + } + } + } + + // Tier 3: fallback + PolicyDecision { + outcome: self.defaults.authenticated_fallback.clone(), + effects: self.defaults.fallback_effects.clone(), + } + } +} + +/// Returns `true` when `rule` matches the given resource, path and method. +fn matches_rule(rule: &PolicyRule, kind: &ResourceKind, path: &str, method: &str) -> bool { + if rule.resource != *kind { + return false; + } + let pattern = match Pattern::new(&rule.pattern) { + Ok(p) => p, + Err(_) => return false, + }; + if !pattern.matches(path) { + return false; + } + rule.methods.is_empty() || rule.methods.iter().any(|m| m.eq_ignore_ascii_case(method)) +} + +/// BFS expansion of group names following `parent_groups` links. +/// Uses a `HashSet` as cycle guard, so circular references are silently +/// ignored. +fn expand_groups(user_groups: &[String], all_groups: &[FileRbacGroup]) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut order: Vec = Vec::new(); + let mut queue: VecDeque = user_groups.iter().cloned().collect(); + + while let Some(g) = queue.pop_front() { + if seen.insert(g.clone()) { + order.push(g.clone()); + if let Some(group) = all_groups.iter().find(|gr| gr.name == g) { + for parent in &group.parent_groups { + if !seen.contains(parent.as_str()) { + queue.push_back(parent.clone()); + } + } + } + } + } + order +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_config() -> FileRbacConfig { + FileRbacConfig { + groups: vec![ + FileRbacGroup { + name: "admin".to_string(), + parent_groups: vec![], + permissions: vec![PolicyRule { + resource: ResourceKind::Endpoint, + pattern: "/api/*".to_string(), + outcome: RbacOutcome::Allow, + methods: vec![], + effects: vec![], + }], + }, + FileRbacGroup { + name: "user".to_string(), + parent_groups: vec![], + permissions: vec![PolicyRule { + resource: ResourceKind::Page, + pattern: "/*".to_string(), + outcome: RbacOutcome::Allow, + methods: vec![], + effects: vec![], + }], + }, + FileRbacGroup { + name: "moderator".to_string(), + parent_groups: vec!["user".to_string()], + permissions: vec![], + }, + ], + user_overrides: vec![FileRbacUserOverride { + id: "blocked@test.com".to_string(), + permissions: vec![PolicyRule { + resource: ResourceKind::Page, + pattern: "/*".to_string(), + outcome: RbacOutcome::Deny, + methods: vec![], + effects: vec![], + }], + }], + defaults: FileRbacDefaults { + unauthenticated_allow: vec![ + PolicyRule { + resource: ResourceKind::Page, + pattern: "/login".to_string(), + outcome: RbacOutcome::Allow, + methods: vec![], + effects: vec![], + }, + PolicyRule { + resource: ResourceKind::Asset, + pattern: "/*".to_string(), + outcome: RbacOutcome::Allow, + methods: vec![], + effects: vec![], + }, + ], + unauthenticated_deny: vec![], + authenticated_fallback: RbacOutcome::Deny, + unauthenticated_fallback: RbacOutcome::Deny, + fallback_effects: vec![], + }, + } + } + + #[test] + fn unauthenticated_allow_login() { + let cfg = make_config(); + let d = cfg.evaluate(None, None, ResourceKind::Page, "/login", "GET"); + assert_eq!(d.outcome, RbacOutcome::Allow); + } + + #[test] + fn unauthenticated_deny_protected_page() { + let cfg = make_config(); + let d = cfg.evaluate(None, None, ResourceKind::Page, "/dashboard", "GET"); + assert_eq!(d.outcome, RbacOutcome::Deny); + } + + #[test] + fn unauthenticated_allow_asset() { + let cfg = make_config(); + let d = cfg.evaluate(None, None, ResourceKind::Asset, "/styles/main.css", "GET"); + assert_eq!(d.outcome, RbacOutcome::Allow); + } + + #[test] + fn admin_can_access_api() { + let cfg = make_config(); + let groups = vec!["admin".to_string()]; + let d = cfg.evaluate( + Some(&groups), + Some("admin@test.com"), + ResourceKind::Endpoint, + "/api/users", + "GET", + ); + assert_eq!(d.outcome, RbacOutcome::Allow); + } + + #[test] + fn user_cannot_access_api() { + let cfg = make_config(); + let groups = vec!["user".to_string()]; + let d = cfg.evaluate( + Some(&groups), + Some("user@test.com"), + ResourceKind::Endpoint, + "/api/users", + "GET", + ); + assert_eq!(d.outcome, RbacOutcome::Deny); + } + + #[test] + fn moderator_inherits_user_page_access() { + let cfg = make_config(); + let groups = vec!["moderator".to_string()]; + // moderator has no page rules, inherits from user via parent_groups + let d = cfg.evaluate( + Some(&groups), + Some("mod@test.com"), + ResourceKind::Page, + "/blog", + "GET", + ); + assert_eq!(d.outcome, RbacOutcome::Allow); + } + + #[test] + fn user_override_blocks_all_pages() { + let cfg = make_config(); + let groups = vec!["user".to_string()]; + let d = cfg.evaluate( + Some(&groups), + Some("blocked@test.com"), + ResourceKind::Page, + "/blog", + "GET", + ); + assert_eq!(d.outcome, RbacOutcome::Deny); + } + + #[test] + fn expand_groups_respects_inheritance() { + let groups = vec![ + FileRbacGroup { + name: "child".to_string(), + parent_groups: vec!["parent".to_string()], + permissions: vec![], + }, + FileRbacGroup { + name: "parent".to_string(), + parent_groups: vec!["grandparent".to_string()], + permissions: vec![], + }, + FileRbacGroup { + name: "grandparent".to_string(), + parent_groups: vec![], + permissions: vec![], + }, + ]; + let expanded = expand_groups(&["child".to_string()], &groups); + assert!(expanded.contains(&"child".to_string())); + assert!(expanded.contains(&"parent".to_string())); + assert!(expanded.contains(&"grandparent".to_string())); + } + + #[test] + fn expand_groups_no_infinite_loop_on_cycle() { + let groups = vec![ + FileRbacGroup { + name: "a".to_string(), + parent_groups: vec!["b".to_string()], + permissions: vec![], + }, + FileRbacGroup { + name: "b".to_string(), + parent_groups: vec!["a".to_string()], + permissions: vec![], + }, + ]; + let expanded = expand_groups(&["a".to_string()], &groups); + assert_eq!(expanded.len(), 2); + } +} diff --git a/crates/foundation/crates/rustelo_server/src/rbac/mod.rs b/crates/foundation/crates/rustelo_server/src/rbac/mod.rs index 85fe795..066b7ca 100644 --- a/crates/foundation/crates/rustelo_server/src/rbac/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/rbac/mod.rs @@ -1,27 +1,33 @@ //! Role-Based Access Control (RBAC) Module //! -//! This module provides comprehensive RBAC functionality including: -//! - RBAC configuration and management -//! - Access control middleware -//! - Repository layer for data access -//! - Service layer for business logic -//! - Database integration -//! - Integration examples +//! Two independent layers: +//! +//! 1. **Database-driven RBAC** (`rbac` feature, existing): `conditional`, +//! `config`, `repository`, `service` — UserCategory/UserTag from DB. +//! +//! 2. **File-driven RBAC** (`rbac` feature): `file_config`, `effect_executor`, +//! `ncl_middleware` — policy rules loaded from `rbac.ncl`. Hot-reload is +//! activated by the `rbac-watcher` feature. +#[cfg(feature = "auth")] pub mod conditional; +#[cfg(feature = "auth")] pub mod config; -pub mod middleware; -pub mod repository; -pub mod service; -// Example files disabled (not critical for library) -// pub mod integration_example; -// pub mod main; -// pub mod server; +// File-based RBAC layer (requires `rbac` feature which includes `auth`) +#[cfg(feature = "rbac")] +pub mod effect_executor; +#[cfg(feature = "rbac")] +pub mod file_config; +#[cfg(feature = "rbac")] +pub mod ncl_middleware; -// Re-export commonly used types and functions -pub use conditional::{ConditionalRBACService, NoOpRBACService}; +// Hot-reload watcher (requires notify dep via `rbac-watcher` feature) +#[cfg(feature = "rbac-watcher")] +pub mod watcher; + +// Re-export database-driven RBAC types (requires auth feature) +#[cfg(feature = "auth")] +pub use conditional::ConditionalRBACService; +#[cfg(feature = "auth")] pub use config::{RBACConfigLoader, RBACSettings, RBACTomlConfig}; -pub use middleware::{rbac_middleware, require_database_access, require_file_access}; -pub use repository::RBACRepository; -pub use service::RBACService; diff --git a/crates/foundation/crates/rustelo_server/src/rbac/ncl_middleware.rs b/crates/foundation/crates/rustelo_server/src/rbac/ncl_middleware.rs new file mode 100644 index 0000000..0654b76 --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/rbac/ncl_middleware.rs @@ -0,0 +1,178 @@ +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; +use rustelo_core_lib::auth::Role; +use tokio::sync::RwLock; + +use super::{ + effect_executor::{execute_effect, EffectContext}, + file_config::{FileRbacConfig, PolicyEffect, RbacOutcome, ResourceKind}, +}; +use crate::auth::middleware::AuthContext; + +/// Map a `Role` variant to the lowercase group name used in `rbac.ncl`. +fn role_to_group(role: &Role) -> String { + match role { + Role::Admin => "admin".to_string(), + Role::Moderator => "moderator".to_string(), + Role::User => "user".to_string(), + Role::Guest => "guest".to_string(), + Role::Custom(s) => s.clone(), + } +} + +/// Compact label for an effect, used in log output. +fn effect_label(effect: &PolicyEffect) -> &'static str { + match effect { + PolicyEffect::Redirect { .. } => "redirect", + PolicyEffect::Log { .. } => "log", + PolicyEffect::AuditLog { .. } => "audit_log", + PolicyEffect::Notify { .. } => "notify", + PolicyEffect::Response { .. } => "response", + } +} + +/// Comma-separated effect labels for a slice of effects. +fn effects_summary(effects: &[PolicyEffect]) -> String { + if effects.is_empty() { + return String::new(); + } + effects + .iter() + .map(effect_label) + .collect::>() + .join(", ") +} + +/// Infer `ResourceKind` from the request path using well-known path prefixes. +/// +/// - `/api/` prefix → `Endpoint` +/// - Static asset prefixes (`/pkg/`, `/public/`, `/styles/`, `/js/`, `/css/`, +/// `/images/`, `/logos/`, `/assets/`) → `Asset` +/// - Everything else → `Page` +fn infer_resource_kind(path: &str) -> ResourceKind { + if path.starts_with("/api/") || path == "/api" { + return ResourceKind::Endpoint; + } + const ASSET_PREFIXES: &[&str] = &[ + "/pkg/", "/public/", "/styles/", "/js/", "/css/", "/images/", "/logos/", "/assets/", + ]; + if ASSET_PREFIXES.iter().any(|p| path.starts_with(p)) { + return ResourceKind::Asset; + } + ResourceKind::Page +} + +/// File-based RBAC middleware. +/// +/// Reads the shared `FileRbacConfig` under a read-lock (non-blocking) and +/// evaluates the policy for the current request. Effects are executed in +/// declaration order. The first terminant effect (`Redirect` or `Response`) +/// short-circuits the chain. If no terminant effect fires: +/// - `Allow` → passes the request to the next handler. +/// - `Deny` → returns `403 Forbidden`. +pub async fn file_rbac_middleware( + State(file_config): State>>, + request: Request, + next: Next, +) -> Response { + let path = request.uri().path().to_string(); + let method = request.method().as_str().to_string(); + + // Extract AuthContext injected by auth_middleware (may be absent when + // the auth feature is disabled or the request is unauthenticated). + let auth_ctx = request.extensions().get::().cloned(); + + let (user_groups_owned, user_id_owned, user_email_owned): ( + Option>, + Option, + Option, + ) = match &auth_ctx { + Some(ctx) if ctx.user.is_some() => { + let user = ctx.user.as_ref().unwrap(); + let groups: Vec = user.roles.iter().map(role_to_group).collect(); + // Use email as the user identity key (matches user_overrides[].id in rbac.ncl). + let id = Some(user.email.clone()); + let email = Some(user.email.clone()); + (Some(groups), id, email) + } + _ => (None, None, None), + }; + + let user_groups_slice: Option<&[String]> = user_groups_owned.as_deref(); + let user_id: Option<&str> = user_id_owned.as_deref(); + let user_email: Option<&str> = user_email_owned.as_deref(); + + let kind = infer_resource_kind(&path); + let kind_str = match kind { + ResourceKind::Endpoint => "endpoint", + ResourceKind::Page => "page", + ResourceKind::Asset => "asset", + }; + + let decision = { + let cfg = file_config.read().await; + cfg.evaluate(user_groups_slice, user_id, kind, &path, &method) + }; + + let user_label = user_id.unwrap_or("anonymous"); + let groups_label = user_groups_owned + .as_deref() + .map(|g| g.join(",")) + .unwrap_or_default(); + + match decision.outcome { + RbacOutcome::Deny => { + tracing::info!( + method = %method, + path = %path, + resource = kind_str, + user = user_label, + groups = %groups_label, + effects = %effects_summary(&decision.effects), + "rbac deny" + ); + } + RbacOutcome::Allow if !decision.effects.is_empty() => { + tracing::debug!( + method = %method, + path = %path, + resource = kind_str, + user = user_label, + effects = %effects_summary(&decision.effects), + "rbac allow with effects" + ); + } + _ => {} + } + + let effect_ctx = EffectContext { + path: &path, + method: &method, + user_id, + user_email, + }; + + // Execute effects in order; stop at the first terminant. + for effect in &decision.effects { + tracing::debug!( + effect = effect_label(effect), + path = %path, + user = user_label, + "rbac executing effect" + ); + if let Some(response) = execute_effect(effect, &effect_ctx).await { + return response; + } + } + + match decision.outcome { + RbacOutcome::Allow => next.run(request).await, + RbacOutcome::Deny => StatusCode::FORBIDDEN.into_response(), + } +} diff --git a/crates/foundation/crates/rustelo_server/src/rbac/service.rs b/crates/foundation/crates/rustelo_server/src/rbac/service.rs index 4cd5f9f..c666bf8 100644 --- a/crates/foundation/crates/rustelo_server/src/rbac/service.rs +++ b/crates/foundation/crates/rustelo_server/src/rbac/service.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; -use super::rbac_repository::{AccessAuditEntry, PermissionCacheEntry, RBACRepository}; +use super::repository::{AccessAuditEntry, PermissionCacheEntry, RBACRepository}; /// RBAC Service for managing access control #[derive(Clone)] diff --git a/crates/foundation/crates/rustelo_server/src/rbac/watcher.rs b/crates/foundation/crates/rustelo_server/src/rbac/watcher.rs new file mode 100644 index 0000000..ddac44d --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/rbac/watcher.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use tokio::sync::{broadcast, RwLock}; + +use super::file_config::{FileRbacConfig, RbacOutcome, ResourceKind}; +use crate::content::websocket::ContentNotification; + +/// Watch `path` for modifications and atomically reload the shared `config`. +/// +/// Spawns a `notify` watcher on `path` (non-recursive). Any `Modify` event +/// triggers a `FileRbacConfig::load` attempt; on success the lock is updated +/// and `policy_tx` receives a `RbacPolicyUpdated` broadcast so WASM clients +/// can sync without a page reload. +/// +/// `deny_cache` is an optional shared cache of the current deny paths. When +/// provided, it is updated atomically alongside the config so new WS clients +/// get an accurate initial policy on connect. +/// +/// The future completes only when the mpsc sender is dropped (i.e. the watcher +/// is dead), which normally never happens during a server's lifetime. +pub async fn watch_file_rbac_with_cache( + path: PathBuf, + config: Arc>, + policy_tx: broadcast::Sender, + deny_cache: Option>>>, +) -> notify::Result<()> { + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(8); + + let mut watcher = RecommendedWatcher::new( + move |res: notify::Result| { + if let Ok(ev) = res { + if matches!(ev.kind, EventKind::Modify(_)) { + // blocking_send is fine here: the channel has capacity 8 + // and the async consumer always drains it promptly. + let _ = tx.blocking_send(()); + } + } + }, + Config::default(), + )?; + + watcher.watch(&path, RecursiveMode::NonRecursive)?; + + while rx.recv().await.is_some() { + match FileRbacConfig::load(&path) { + Ok(new_cfg) => { + tracing::info!( + path = %path.display(), + groups = new_cfg.groups.len(), + user_overrides = new_cfg.user_overrides.len(), + "rbac.ncl reloaded — new policy active" + ); + let deny_paths = extract_anonymous_deny_paths(&new_cfg); + *config.write().await = new_cfg; + if let Some(ref cache) = deny_cache { + *cache.write().await = deny_paths.clone(); + } + let _ = policy_tx.send(ContentNotification::rbac_policy_updated(deny_paths)); + } + Err(e) => { + tracing::error!( + path = %path.display(), + error = %e, + "rbac.ncl reload failed — previous policy kept" + ); + } + } + } + + Ok(()) +} + +/// Extract URL patterns denied for anonymous users from a loaded +/// `FileRbacConfig`. +/// +/// Mirrors `build_config::rbac_config::load_rbac_anonymous_deny_patterns` but +/// operates on the runtime struct (no Nickel re-evaluation needed). +pub fn extract_anonymous_deny_paths(cfg: &FileRbacConfig) -> Vec { + cfg.defaults + .unauthenticated_allow + .iter() + .filter(|r| r.resource == ResourceKind::Page && r.outcome == RbacOutcome::Deny) + .map(|r| r.pattern.clone()) + .collect() +} diff --git a/crates/foundation/crates/rustelo_server/src/routes.rs b/crates/foundation/crates/rustelo_server/src/routes.rs index f4556e2..49a8d9e 100644 --- a/crates/foundation/crates/rustelo_server/src/routes.rs +++ b/crates/foundation/crates/rustelo_server/src/routes.rs @@ -1,14 +1,14 @@ use crate::handlers; use axum::{routing::get, Router}; -use leptos_config::LeptosOptions; +use crate::run::SiteOptions; use std::sync::Arc; -pub fn api_routes() -> Router> { +pub fn api_routes() -> Router> { Router::new() .route("/api/test", get(handlers::test_handler)) .with_state(()) } -pub fn create_server_routes(_leptos_options: Arc) -> Router> { +pub fn create_server_routes(_site_options: Arc) -> Router> { Router::new().merge(api_routes()) } diff --git a/crates/foundation/crates/rustelo_server/src/routing.rs b/crates/foundation/crates/rustelo_server/src/routing.rs index bc98520..bd3682a 100644 --- a/crates/foundation/crates/rustelo_server/src/routing.rs +++ b/crates/foundation/crates/rustelo_server/src/routing.rs @@ -11,7 +11,7 @@ use leptos::prelude::*; use rustelo_core_types::{ AppContext, DefaultLocalizationProvider, DefaultPageProvider, LanguageDetector, LocalizationProvider, PageProvider, RouteHandler, RouteMetadataProvider, RouteParameters, - RouteRenderer, RouteResolver, RouteResult, RoutingResult, + RouteRenderer, RouteResult, RoutingResult, }; /// Server-side route renderer using trait-based dependency injection for SSR @@ -86,7 +86,7 @@ impl RouteRenderer for ServerRouteRenderer { } fn render_not_found(&self, _path: &str, language: &str) -> RoutingResult { - use rustelo_pages::not_found::unified::UnifiedNotFoundPage; + use rustelo_pages_leptos::not_found::unified::UnifiedNotFoundPage; let lang = language.to_string(); // Render UnifiedNotFoundPage without explicit lang_content @@ -108,11 +108,15 @@ impl RouteMetadataProvider for ServerRouteRenderer { .localization_provider .get_localized_string(&title_key, language, &fallback_args) { - Ok(title) => Ok(format!("{} - Rustelo", title)), + Ok(title) => Ok(format!("{} - {}", title, crate::utils::site_name())), Err(_) => { // Fallback to component name let formatted_title = component.replace("-", " ").replace("_", " "); - Ok(format!("{} - Rustelo", formatted_title)) + Ok(format!( + "{} - {}", + formatted_title, + crate::utils::site_name() + )) } } } @@ -186,9 +190,9 @@ impl RouteHandler for ServerRouteRenderer { let title = component .map(|c| { self.get_title(c, language) - .unwrap_or_else(|_| "Rustelo".to_string()) + .unwrap_or_else(|_| crate::utils::site_name()) }) - .unwrap_or_else(|| "Page Not Found - Rustelo".to_string()); + .unwrap_or_else(|| format!("Page Not Found - {}", crate::utils::site_name())); let description = component .map(|c| { @@ -227,7 +231,7 @@ pub fn render_page_content_ssr_with_language(path: &str, language: &str) -> AnyV let renderer = ServerRouteRenderer::new(); let app_context = AppContext::new(); - // Use trait-based language detection and route resolution + // Use trait-based language detection let (detected_lang, clean_path) = app_context.language_detector.extract_language(path); let final_language = if language != "auto" { language @@ -235,29 +239,33 @@ pub fn render_page_content_ssr_with_language(path: &str, language: &str) -> AnyV &detected_lang }; - // Use trait-based route resolution - match app_context.route_resolver.resolve_route(&clean_path) { - Ok(resolution) => { - match renderer.handle_route( - resolution.component.as_ref(), - &resolution.path, - final_language, - &resolution.parameters, - ) { - Ok(route_result) => route_result.view, - Err(_) => { - // Fallback to not found - renderer - .render_not_found(path, final_language) - .unwrap_or_else(|_| view! {
    "Error loading page"
    }.into_any()) - } - } - } + // Normalize path with leading slash for route lookup + // clean_path is the path without language prefix, but may lack leading slash + let lookup_path = if clean_path.starts_with('/') { + clean_path.to_string() + } else { + format!("/{}", clean_path) + }; + + // Use actual route configuration from rustelo_core_lib instead of hardcoded + // resolver + let component = + rustelo_core_lib::routing::config::get_route_for_language(&lookup_path, final_language) + .map(|route_config| route_config.component.clone()); + + // Handle the resolved component + match renderer.handle_route( + component.as_ref(), + &lookup_path, + final_language, + &RouteParameters::new(), + ) { + Ok(route_result) => route_result.view, Err(_) => { - // Route resolution failed, show not found + // Fallback to not found renderer .render_not_found(path, final_language) - .unwrap_or_else(|_| view! {
    "Page not found"
    }.into_any()) + .unwrap_or_else(|_| view! {
    "Error loading page"
    }.into_any()) } } } @@ -272,7 +280,7 @@ pub fn get_page_metadata_with_language(path: &str, language: &str) -> (String, S let renderer = ServerRouteRenderer::new(); let app_context = AppContext::new(); - // Use trait-based language detection and route resolution + // Use trait-based language detection let (detected_lang, clean_path) = app_context.language_detector.extract_language(path); let final_language = if language != "auto" { language @@ -280,32 +288,34 @@ pub fn get_page_metadata_with_language(path: &str, language: &str) -> (String, S &detected_lang }; - // Use trait-based route resolution for metadata - match app_context.route_resolver.resolve_route(&clean_path) { - Ok(resolution) => { - match renderer.handle_route( - resolution.component.as_ref(), - &resolution.path, - final_language, - &resolution.parameters, - ) { - Ok(route_result) => ( - route_result.title, - route_result.description, - route_result.keywords, - ), - Err(_) => { - // Fallback metadata - ( - "Page Not Found - Rustelo".to_string(), - "The requested page was not found".to_string(), - "rustelo, not found".to_string(), - ) - } - } - } + // Normalize path with leading slash for route lookup + // clean_path is the path without language prefix, but may lack leading slash + let lookup_path = if clean_path.starts_with('/') { + clean_path.to_string() + } else { + format!("/{}", clean_path) + }; + + // Use actual route configuration from rustelo_core_lib instead of hardcoded + // resolver + let component = + rustelo_core_lib::routing::config::get_route_for_language(&lookup_path, final_language) + .map(|route_config| route_config.component.clone()); + + // Get metadata for the resolved component + match renderer.handle_route( + component.as_ref(), + &lookup_path, + final_language, + &RouteParameters::new(), + ) { + Ok(route_result) => ( + route_result.title, + route_result.description, + route_result.keywords, + ), Err(_) => { - // Route resolution failed, return default metadata + // Fallback metadata ( "Page Not Found - Rustelo".to_string(), "The requested page was not found".to_string(), diff --git a/crates/foundation/crates/rustelo_server/src/run.rs b/crates/foundation/crates/rustelo_server/src/run.rs index 1aa15b6..5a97924 100644 --- a/crates/foundation/crates/rustelo_server/src/run.rs +++ b/crates/foundation/crates/rustelo_server/src/run.rs @@ -134,62 +134,404 @@ // #![allow(unused_variables)] // #![recursion_limit = "512"] +use std::sync::{Arc, OnceLock}; + +use rustelo_core_types::rendering::{ + workspace_rendering_config, workspace_rendering_profile, RenderingConfig, RenderingProfile, +}; + +#[cfg(feature = "leptos-hydration")] use crate::shell::shell_with_path; +use crate::templating::TemplateEngine; -/// Helper function to render SSR with proper reactive context -pub fn render_ssr_with_context(leptos_options: LeptosOptions, path: Option) -> String { - use leptos::prelude::*; - - // Create a proper reactive context for SSR rendering - let owner = Owner::new(); - owner.with(|| shell_with_path(leptos_options, path).to_html()) +/// Minimal site configuration passed to shell renderers. +/// +/// Replaces `leptos_config::LeptosOptions` in all public renderer APIs so that +/// `leptos_config` is not required for htmx-only builds. Carries only what the +/// shells and static-file serving need: the listen address and the site root. +#[derive(Clone, Debug)] +pub struct SiteOptions { + /// Address the server listens on. + pub site_addr: std::net::SocketAddr, + /// Filesystem root for serving the site tree (e.g. `"site"` or + /// `"/var/www/site"`). + pub site_root: String, } -/// Helper function to render SSR using routing engine's resolution +/// v1 shell renderer callback: receives site options and path. +pub type ShellRendererV1 = fn(SiteOptions, Option) -> String; + +/// v2 shell renderer callback: receives site options, path, the resolved +/// rendering profile, and the list of htmx extensions to enable in the shell. +/// +/// Per-request headers are not available here — use [`current_request_headers`] +/// inside the renderer body to access cookies/HX-* headers. +pub type ShellRendererV2 = fn(SiteOptions, Option, RenderingProfile, Vec) -> String; + +tokio::task_local! { + /// Per-request `HeaderMap` set by the dispatcher before invoking the + /// shell. Shells read it with [`current_request_headers`] to make + /// rendering decisions based on cookies/HX-* headers (e.g. omit the + /// cookie banner when consent is already present). + static REQUEST_HEADERS: axum::http::HeaderMap; +} + +/// Read the current request's headers from the shell. +/// +/// Returns `None` when called outside a dispatcher-scoped task (e.g. in +/// tests that bypass `render_route_response`). Shells should treat `None` as +/// "no information"; never error out. +pub fn current_request_headers() -> Option { + REQUEST_HEADERS.try_with(|h| h.clone()).ok() +} + +/// Fragment renderer callback: returns the page body only, without the shell. +pub type FragmentRenderer = + fn(SiteOptions, &rustelo_core_lib::routing::engine::RouteResolution) -> String; + +static SHELL_RENDERER: OnceLock = OnceLock::new(); + +/// Profile-aware shell renderer override. Takes precedence over the v1 +/// callback. +static SHELL_RENDERER_V2: OnceLock = OnceLock::new(); + +/// HTMX fragment renderer override — returns only the body fragment for a +/// route, without the page shell or hydration scripts. Required for htmx-ssr +/// requests. +/// +/// Implementations register the function that knows how to invoke their root +/// page component for a given resolution and serialize the output to a String. +/// When unset, the dispatcher falls back to the full shell with a warning. +static FRAGMENT_RENDERER: OnceLock = OnceLock::new(); + +/// Shared Minijinja engine used to render `kind = "template"` routes. +/// Registered by the implementation at startup via +/// [`register_template_engine`]. +static TEMPLATE_ENGINE: OnceLock> = OnceLock::new(); + +/// Install the Minijinja [`TemplateEngine`] shared by all template routes. +/// +/// Must be called before the server starts handling requests. Subsequent calls +/// are silently ignored. +pub fn register_template_engine(engine: Arc) { + let _ = TEMPLATE_ENGINE.set(engine); +} + +/// Read the `[rendering]` table from a NCL or TOML config file and apply it to +/// the workspace rendering registry. +/// +/// Accepts both `.ncl` (parsed via the bundled nickel CLI) and `.toml` inputs. +/// Returns the applied [`RenderingConfig`] on success, or `None` when the file +/// contains no `[rendering]` table (the registry then keeps its default). +/// +/// Failure modes are logged via `tracing::warn!` and surfaced through the +/// returned `Result` so callers can decide whether to abort startup. Most sites +/// pass the error through to `eprintln!` and continue with the default profile. +pub fn install_workspace_rendering_from_file( + path: &std::path::Path, +) -> Result, RenderingLoadError> { + if !path.exists() { + return Err(RenderingLoadError::FileNotFound(path.to_path_buf())); + } + let extension = path.extension().and_then(|s| s.to_str()).unwrap_or(""); + let json = match extension { + "ncl" => nickel_export_to_json(path)?, + "toml" => { + let raw = std::fs::read_to_string(path).map_err(RenderingLoadError::Io)?; + // Round-trip through a toml::Value → serde_json::Value to reuse the + // same deserialiser path the NCL branch uses. + let value: toml::Value = toml::from_str(&raw).map_err(|e| { + RenderingLoadError::Parse(format!("invalid TOML in {}: {e}", path.display())) + })?; + serde_json::to_string(&value) + .map_err(|e| RenderingLoadError::Parse(format!("toml→json failed: {e}")))? + } + other => { + return Err(RenderingLoadError::Parse(format!( + "unsupported extension `{other}` (expected .ncl or .toml)" + ))) + } + }; + apply_rendering_json(&json) +} + +#[derive(Debug)] +pub enum RenderingLoadError { + FileNotFound(std::path::PathBuf), + Io(std::io::Error), + NickelCli(String), + Parse(String), +} + +impl std::fmt::Display for RenderingLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::FileNotFound(p) => write!(f, "rendering config not found: {}", p.display()), + Self::Io(e) => write!(f, "rendering config io error: {e}"), + Self::NickelCli(msg) => write!(f, "nickel export failed: {msg}"), + Self::Parse(msg) => write!(f, "rendering config parse error: {msg}"), + } + } +} + +impl std::error::Error for RenderingLoadError {} + +fn nickel_export_to_json(path: &std::path::Path) -> Result { + let output = std::process::Command::new("nickel") + .arg("export") + .arg("--format") + .arg("json") + .arg(path) + .output() + .map_err(|e| RenderingLoadError::NickelCli(format!("could not run `nickel`: {e}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(RenderingLoadError::NickelCli(stderr)); + } + String::from_utf8(output.stdout) + .map_err(|e| RenderingLoadError::Parse(format!("nickel stdout not UTF-8: {e}"))) +} + +fn apply_rendering_json(json: &str) -> Result, RenderingLoadError> { + #[derive(serde::Deserialize)] + struct Wrapper { + rendering: Option, + } + let wrapper: Wrapper = serde_json::from_str(json).map_err(|e| { + RenderingLoadError::Parse(format!( + "expected JSON with optional `rendering` field: {e}" + )) + })?; + if let Some(cfg) = wrapper.rendering.clone() { + rustelo_core_types::rendering::set_workspace_rendering_config(cfg); + } + Ok(wrapper.rendering) +} + +/// Access the registered Minijinja engine, when present. +pub fn template_engine() -> Option> { + TEMPLATE_ENGINE.get().cloned() +} + +/// Register a custom shell renderer so implementations can control the HTML +/// shell without modifying the framework. +/// +/// Must be called before `run_server()`. Subsequent calls are silently ignored. +pub fn register_shell_renderer(renderer: ShellRendererV1) { + let _ = SHELL_RENDERER.set(renderer); +} + +/// Register a profile-aware shell renderer. When set, takes precedence over the +/// v1 callback so the renderer can switch between the Leptos hydration and HTMX +/// SSR shells per request. +pub fn register_shell_renderer_v2(renderer: ShellRendererV2) { + let _ = SHELL_RENDERER_V2.set(renderer); +} + +/// Register the htmx-ssr fragment renderer. +/// +/// Without this registered, requests with `HX-Request: true` to htmx-ssr routes +/// fall back to the full shell and log a warning — the swap will still work but +/// duplicates the shell content. +pub fn register_fragment_renderer(renderer: FragmentRenderer) { + let _ = FRAGMENT_RENDERER.set(renderer); +} + +fn render_shell( + site_options: SiteOptions, + path: Option, + profile: RenderingProfile, +) -> String { + let htmx_extensions = if matches!(profile, RenderingProfile::HtmxSsr) { + workspace_rendering_config().htmx_extensions.clone() + } else { + Vec::new() + }; + if let Some(renderer) = SHELL_RENDERER_V2.get() { + return renderer(site_options, path, profile, htmx_extensions); + } + if let Some(renderer) = SHELL_RENDERER.get() { + return renderer(site_options, path); + } + #[cfg(feature = "leptos-hydration")] + { + use leptos::prelude::*; + let owner = Owner::new(); + return owner + .with(|| shell_with_path(site_options, path, profile, htmx_extensions).to_html()); + } + #[cfg(not(feature = "leptos-hydration"))] + { + tracing::warn!( + "render_shell called with no registered renderer and leptos-hydration disabled — \ + returning empty string" + ); + String::new() + } +} + +/// Helper function to render SSR with proper reactive context (Owner needed for +/// / route). Uses the workspace-default [`RenderingProfile`]. +pub fn render_ssr_with_context(site_options: SiteOptions, path: Option) -> String { + render_shell(site_options, path, workspace_rendering_profile()) +} + +/// Render SSR with an explicit [`RenderingProfile`] override. +pub fn render_ssr_with_profile( + site_options: SiteOptions, + path: Option, + profile: RenderingProfile, +) -> String { + render_shell(site_options, path, profile) +} + +/// Helper function to render SSR using routing engine's resolution. +/// +/// The effective [`RenderingProfile`] is taken from the resolution itself +/// (already resolved via route -> workspace -> default precedence). pub fn render_ssr_with_route_resolution( - leptos_options: LeptosOptions, + site_options: SiteOptions, resolution: &rustelo_core_lib::routing::engine::RouteResolution, ) -> String { - // Use the resolved path directly from the routing engine - // The routing engine has already determined the correct path based on TOML - // configuration let path = resolution.path.clone(); + let profile = resolution.rendering_profile; tracing::info!( - "🔧 render_ssr_with_route_resolution using resolved path: '{}'", - path + "🔧 render_ssr_with_route_resolution using resolved path: '{}' profile: {}", + path, + profile ); - // Use the existing shell function with the resolved path - // This ensures header, footer, and layout are included - shell_with_path(leptos_options, Some(path)).to_html() + render_shell(site_options, Some(path), profile) } -// Moved CSRF and RateLimiter imports to app_state module -#[cfg(any(feature = "auth", feature = "content-db"))] -use std::sync::Arc; +/// Render only the page body without the HTML shell, for HTMX fragment swaps. +/// +/// Delegates to the [`FRAGMENT_RENDERER`] registered by the implementation; +/// when none is registered, returns `None` and the caller must fall back. +pub fn render_fragment_with_route_resolution( + site_options: SiteOptions, + resolution: &rustelo_core_lib::routing::engine::RouteResolution, +) -> Option { + FRAGMENT_RENDERER + .get() + .map(|renderer| renderer(site_options, resolution)) +} -#[cfg(feature = "auth")] -use auth::{ - auth_middleware, create_auth_routes, AuthService, JwtService, OAuthService, PasswordService, - TwoFactorService, -}; -#[cfg(feature = "content-db")] -use content::{create_content_routes, ContentRepository, ContentService, ContentSource}; -#[cfg(any(feature = "auth", feature = "content-db"))] -use database::{Database, DatabaseConfig, DatabasePool}; -#[cfg(feature = "email")] -use email::{EmailService, EmailServiceBuilder}; -#[cfg(feature = "email")] -use handlers::email::create_email_routes; -use leptos::prelude::*; -#[cfg(feature = "metrics")] -use metrics::{create_metrics_routes, metrics_middleware, MetricsService}; -#[cfg(feature = "auth")] -use tower_cookies::CookieManagerLayer; +/// Decide between full-page, fragment, and template-engine rendering based on +/// request headers and the route resolution. +/// +/// Decision matrix: +/// +/// | resolution.template | profile | HX-Request | HX-Boosted | response | +/// |---------------------|------------------|-----------:|-----------:|---------------------------| +/// | `Some(_)` | any | false | any | full page via `TemplateEngine` | +/// | `Some(_)` | any | true | false | block fragment via `TemplateEngine` | +/// | `Some(_)` | any | true | true | full page (boosted nav) | +/// | `None` | LeptosHydration | any | any | full shell (hydration) | +/// | `None` | HtmxSsr | false | any | full shell (no WASM) | +/// | `None` | HtmxSsr | true | true | full shell (boosted nav) | +/// | `None` | HtmxSsr | true | false | body fragment | +pub fn render_route_response( + site_options: SiteOptions, + resolution: &rustelo_core_lib::routing::engine::RouteResolution, + headers: &axum::http::HeaderMap, +) -> axum::response::Response { + // Make request headers available to the registered shell/fragment + // renderers via `current_request_headers()`. The task_local lives only + // for the synchronous extent of this call; sync_scope unsets it on exit. + let headers_clone = headers.clone(); + REQUEST_HEADERS.sync_scope(headers_clone, move || { + render_route_response_inner(site_options, resolution, headers) + }) +} + +fn render_route_response_inner( + site_options: SiteOptions, + resolution: &rustelo_core_lib::routing::engine::RouteResolution, + headers: &axum::http::HeaderMap, +) -> axum::response::Response { + use axum::http::StatusCode; + use axum::response::IntoResponse; + + use crate::utils::htmx::{htmx_error_response, htmx_target, is_htmx_boosted, is_htmx_request}; + + let htmx_request = is_htmx_request(headers); + let boosted = is_htmx_boosted(headers); + + // Template-engine routes bypass Leptos entirely. + if let Some(template_name) = resolution.template.as_deref() { + let Some(engine) = template_engine() else { + tracing::warn!( + template = %template_name, + "kind=template route requested but TemplateEngine not registered" + ); + return htmx_error_response( + headers, + StatusCode::SERVICE_UNAVAILABLE, + "template engine not registered", + ); + }; + + let ctx = build_template_context(resolution); + let result = if htmx_request && !boosted { + // Block name is derived from the HX-Target header (CSS id minus leading `#`). + let block = htmx_target(headers) + .map(|t| t.trim_start_matches('#').to_string()) + .unwrap_or_else(|| "content".to_string()); + engine.render_block(template_name, &block, ctx) + } else { + engine.render_page(template_name, ctx) + }; + + return match result { + Ok(body) => axum::response::Html(body).into_response(), + Err(e) => { + tracing::error!( + template = %template_name, + error = %e, + "template render failed" + ); + htmx_error_response(headers, StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + } + }; + } + + // Component routes: existing profile dispatch. + if matches!(resolution.rendering_profile, RenderingProfile::HtmxSsr) && htmx_request && !boosted + { + if let Some(body) = render_fragment_with_route_resolution(site_options.clone(), resolution) + { + return axum::response::Html(body).into_response(); + } + tracing::warn!( + path = %resolution.path, + "htmx fragment request received but no fragment renderer registered; falling back to full shell", + ); + } + + axum::response::Html(render_ssr_with_route_resolution(site_options, resolution)).into_response() +} + +/// Build the default Minijinja context exposed to template routes. +fn build_template_context( + resolution: &rustelo_core_lib::routing::engine::RouteResolution, +) -> minijinja::Value { + minijinja::context! { + path => resolution.path.clone(), + canonical_path => resolution.canonical_path.clone(), + language => resolution.language.clone(), + parameters => resolution.parameters.clone(), + rendering_profile => resolution.rendering_profile.as_str(), + } +} // Import AppState from the dedicated app_state module use crate::app_state::AppState; +#[cfg(feature = "content-db")] +use crate::content::{create_content_routes, ContentRepository, ContentService, ContentSource}; +#[cfg(feature = "metrics")] +use crate::metrics::{create_metrics_routes, metrics_middleware, MetricsService}; /// Run the Axum server with all features and routes properly configured /// @@ -211,35 +553,53 @@ pub async fn run_server() -> Result<(), Box> { // 4. Initialize navigation tracking system initialize_navigation_tracking(&config)?; - // 4. Initialize meta configuration preloader for SSR (after logging is set up) + // Initialize any_spawner executor for Leptos reactive graph SSR. Only + // needed when leptos-hydration is active — htmx-ssr has no reactive signals. + #[cfg(feature = "leptos-hydration")] + let _ = any_spawner::Executor::init_tokio(); + + // 4b. Initialize meta configuration preloader for SSR (after logging is set up) rustelo_core_lib::init_meta_preloader(); // 5. Initialize cache system let (_cache_dir, _shared_cache_config, content_cache) = initialize_cache_system(&config).await?; - // 6. Setup Leptos configuration - let leptos_options = setup_leptos_config(&config)?; + // 6. Setup site options (addr + root; leptos-hydration converts to + // LeptosOptions internally) + let site_options = setup_site_options(&config)?; // 7. Initialize security components let (csrf_state, rate_limiter, security_headers_config) = initialize_security_components(&config); // 8. Initialize database if needed - #[cfg(any(feature = "auth", feature = "content-db"))] - let (database, pool, database_pool) = initialize_database(&config).await?; + #[cfg(any(feature = "auth", feature = "content-db", feature = "post-engagement"))] + let (_database, _pool, database_pool) = initialize_database(&config).await?; + + // Per-post engagement repository (views/ratings/messages). Shares the same + // database-agnostic pool; mirrors the auth repository wiring. + #[cfg(feature = "post-engagement")] + let engagement_repository = std::sync::Arc::new( + crate::database::engagement::EngagementRepository::from_pool(&database_pool), + ); // 9. Initialize services based on enabled features + #[cfg(feature = "email")] + let email_service = initialize_email_service(&config).await?; + #[cfg(feature = "auth")] - let (auth_service, auth_repository, jwt_service) = - initialize_auth_services(&database_pool, &config).await?; + let (auth_service, auth_repository, jwt_service) = initialize_auth_services( + &database_pool, + &config, + #[cfg(feature = "email")] + Some(email_service.clone()), + ) + .await?; #[cfg(feature = "content-db")] let content_service = initialize_content_services(&database_pool); - #[cfg(feature = "email")] - let email_service = initialize_email_service(&config).await?; - #[cfg(feature = "metrics")] let metrics_result = initialize_metrics_service(&config)?; #[cfg(feature = "metrics")] @@ -248,9 +608,172 @@ pub async fn run_server() -> Result<(), Box> { .map(|(_, registry)| registry.clone()) .flatten(); + // 9b. Initialize file-based RBAC config (loads rbac.ncl; falls back to empty + // policy on error) + #[cfg(feature = "rbac")] + let file_rbac_config = { + use std::sync::Arc; + + use tokio::sync::RwLock; + + use crate::rbac::file_config::FileRbacConfig; + let path = std::path::PathBuf::from(&config.rbac.ncl_path); + let cfg = match FileRbacConfig::load(&path) { + Ok(cfg) => { + tracing::info!( + path = %path.display(), + groups = cfg.groups.len(), + user_overrides = cfg.user_overrides.len(), + "rbac.ncl loaded" + ); + cfg + } + Err(e) => { + tracing::warn!( + path = %path.display(), + error = %e, + "rbac.ncl not found or invalid, using empty policy (all requests allowed)" + ); + FileRbacConfig::default() + } + }; + Arc::new(RwLock::new(cfg)) + }; + + // 9c. Build WebSocket server with RBAC deny-path sync when rbac-watcher is + // enabled. The deny_paths cache bridges the watcher and new WS connections + // for initial sync. + use crate::content::websocket::{WebSocketConfig, WebSocketServer}; + let ws_server = { + use std::sync::Arc; + + #[allow(unused_imports)] + use tokio::sync::RwLock; + + #[cfg(feature = "rbac")] + { + let initial_deny_paths = + crate::rbac::watcher::extract_anonymous_deny_paths(&*file_rbac_config.read().await); + let deny_cache = Arc::new(RwLock::new(initial_deny_paths)); + Arc::new(WebSocketServer::with_deny_paths( + WebSocketConfig::default(), + Arc::clone(&deny_cache), + )) + } + #[cfg(not(feature = "rbac"))] + Arc::new(WebSocketServer::new(WebSocketConfig::default())) + }; + + // 9d. Spawn hot-reload watcher for rbac.ncl — broadcasts RbacPolicyUpdated on + // reload + #[cfg(feature = "rbac-watcher")] + { + use crate::rbac::watcher::watch_file_rbac_with_cache; + let watcher_path = std::path::PathBuf::from(&config.rbac.ncl_path); + let watcher_config = std::sync::Arc::clone(&file_rbac_config); + let policy_tx = ws_server.broadcast_tx.clone(); + let deny_cache = ws_server.current_deny_paths.clone(); + tracing::info!(path = %watcher_path.display(), "rbac file watcher started"); + tokio::spawn(async move { + // Wrap the watcher to also update the deny_cache on reload + if let Err(e) = + watch_file_rbac_with_cache(watcher_path, watcher_config, policy_tx, deny_cache) + .await + { + tracing::error!(error = %e, "rbac file watcher terminated unexpectedly"); + } + }); + } + + // 9e. Connect NATS admin client and spawn subscriber tasks + #[cfg(feature = "nats-admin")] + let nats_client = { + use std::sync::Arc; + + use crate::nats::executor::PipelineContext; + #[cfg(not(feature = "pipeline-watcher"))] + use crate::nats::pipeline::load_pipeline_config; + #[cfg(not(feature = "pipeline-watcher"))] + use crate::startup::nats::spawn_pipeline_subscribers; + use crate::startup::nats::{initialize_nats_admin, spawn_nats_admin_subscribers}; + + match &config.external_services.nats_admin { + Some(nats_cfg) => match initialize_nats_admin(nats_cfg).await { + Ok(Some((client, log_buffer))) => { + spawn_nats_admin_subscribers(client.clone(), log_buffer); + + // Pipeline path comes from config (defaults to + // PIPELINES_NCL_PATH or ./site/pipelines/index.ncl). + let pipeline_path = std::path::PathBuf::from(&config.pipelines.ncl_path); + let scripts_root = config.pipelines.scripts_root.clone(); + + let ctx = Arc::new(PipelineContext { + ws: ws_server.clone(), + cache: content_cache.clone(), + #[cfg(feature = "auth")] + auth_repo: auth_repository.clone(), + scripts_root, + }); + + // Without pipeline-watcher: load once at startup. + #[cfg(not(feature = "pipeline-watcher"))] + match load_pipeline_config(&pipeline_path) { + Ok(cfg) if !cfg.pipelines.is_empty() => { + spawn_pipeline_subscribers(client.clone(), cfg.pipelines, ctx); + } + Ok(_) => {} + Err(e) => { + tracing::warn!( + error = %e, + "pipelines: config load failed, subscriptions disabled" + ); + } + } + + // With pipeline-watcher: the watcher handles initial load + // AND reloads; no separate spawn_pipeline_subscribers call. + #[cfg(feature = "pipeline-watcher")] + { + let watcher_client = client.clone(); + let watcher_ctx = Arc::clone(&ctx); + tracing::info!( + path = %pipeline_path.display(), + "pipeline watcher starting" + ); + tokio::spawn(async move { + if let Err(e) = crate::nats::watcher::watch_pipeline_config( + pipeline_path, + watcher_client, + watcher_ctx, + ) + .await + { + tracing::error!( + error = %e, + "pipeline watcher terminated unexpectedly" + ); + } + }); + } + + Some(Arc::new(client)) + } + Ok(None) => None, + Err(e) => { + tracing::warn!(error = %e, "nats-admin: connection failed, continuing without NATS"); + None + } + }, + None => { + tracing::debug!("nats-admin: not configured in external_services"); + None + } + } + }; + // 10. Create application state let app_state = AppState { - leptos_options: leptos_options.clone(), + site_options: site_options.clone(), csrf_state, rate_limiter, #[cfg(feature = "auth")] @@ -259,16 +782,23 @@ pub async fn run_server() -> Result<(), Box> { jwt_service: jwt_service.clone(), #[cfg(feature = "auth")] auth_repository: auth_repository.clone(), + #[cfg(feature = "post-engagement")] + engagement_repository, #[cfg(feature = "content-db")] content_service: content_service.clone(), #[cfg(feature = "email")] email_service: email_service.clone(), #[cfg(feature = "metrics")] metrics_registry, + #[cfg(feature = "rbac")] + file_rbac_config, + ws_server: ws_server.clone(), + #[cfg(feature = "nats-admin")] + nats_client, }; // 11. Create main application router - let main_app = create_main_router(&config, leptos_options.clone(), app_state.clone())?; + let main_app = create_main_router(&config, site_options.clone(), app_state.clone())?; // 12. Create API router with all service routes let api_router = create_api_router( @@ -281,9 +811,22 @@ pub async fn run_server() -> Result<(), Box> { content_service.clone(), #[cfg(feature = "email")] email_service.clone(), + ws_server.clone(), )?; // 13. Combine routers + // Leptos server functions are registered with prefix "/api" but + // handle_server_fns must receive the full path (including "/api") for + // dispatch to work. Mount it here, outside the nested "/api" router, so + // Axum does not strip the prefix. + // Leptos server-function dispatcher: only needed when leptos-hydration is + // active. htmx-ssr uses conventional Axum routes for all form submissions. + #[cfg(feature = "leptos-hydration")] + let app_with_api = main_app.nest("/api", api_router).route( + "/api/{*fn_name}", + axum::routing::post(leptos_axum::handle_server_fns), + ); + #[cfg(not(feature = "leptos-hydration"))] let app_with_api = main_app.nest("/api", api_router); // 14. Apply all middleware layers @@ -306,20 +849,17 @@ pub async fn run_server() -> Result<(), Box> { /// Main entry point for the Axum/Leptos server. /// -/// Uses current_thread runtime with LocalSet to provide the proper runtime -/// context for Leptos. This fixes the "spawn_local called from outside of a -/// task::LocalSet" error. +/// Uses `tokio::runtime::LocalRuntime` (tokio_unstable) so that `spawn_local` +/// is valid from *any* task context — including connection tasks that +/// `axum::serve` spawns internally via `tokio::spawn`. +/// +/// The previous `current_thread + LocalSet::run_until` pattern broke because +/// `LocalSet` only activates its local context while *its own* future is being +/// polled; tasks spawned by `tokio::spawn` inside `axum::serve` escape that +/// scope and call `spawn_local` → panic. `LocalRuntime` makes the entire +/// runtime "local", eliminating the scope gap. pub fn run_main() -> Result<(), Box> { - // Initialize path utilities first crate::utils::init(); - - // Create a current_thread runtime which is compatible with LocalSet - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - - // Create a LocalSet and run everything within it - let local = tokio::task::LocalSet::new(); - - rt.block_on(local.run_until(async { run_server().await })) + let rt = tokio::runtime::LocalRuntime::new()?; + rt.block_on(run_server()) } diff --git a/crates/foundation/crates/rustelo_server/src/shell.rs b/crates/foundation/crates/rustelo_server/src/shell.rs index 450499a..5ca34d0 100644 --- a/crates/foundation/crates/rustelo_server/src/shell.rs +++ b/crates/foundation/crates/rustelo_server/src/shell.rs @@ -4,34 +4,91 @@ //! and renders actual page components instead of just route information. use leptos::prelude::*; -use leptos_config::LeptosOptions; -use leptos_meta::provide_meta_context; use rustelo_core_lib::routing::utils::detect_language_from_path; use rustelo_core_lib::AssetMode; +use rustelo_core_types::rendering::RenderingProfile; -/// The SSR shell for Leptos/Axum integration with server-side routing -pub fn shell_with_path(_options: LeptosOptions, path: Option) -> impl IntoView { +use crate::run::SiteOptions; + +/// The SSR shell for Leptos/Axum integration with server-side routing. +/// +/// The `profile` parameter selects between the Leptos hydration runtime +/// (default, emits `/pkg/.js` + ` }; - // Create the app shell for SSR with meta context inside the component - let language_clone = language.clone(); - let current_path_clone = current_path.clone(); - let app = move || { - // Provide meta context within the component context - provide_meta_context(); + let is_hydration = matches!(profile, RenderingProfile::LeptosHydration); + let is_htmx = matches!(profile, RenderingProfile::HtmxSsr); + // Leptos hydration: WASM module preload + ESM importer. + let hydration_modulepreload = is_hydration.then(|| { view! { - -
    - -
    - { - // Use the shared routing system directly - crate::routing::render_page_content_ssr(¤t_path_clone) - } -
    - -
    -
    + } + }); + let hydration_bootstrap_script = is_hydration.then(|| { + view! { + + } + }); + let hydration_module_init = is_hydration.then(|| { + view! { + + } + }); + + // HTMX runtime: vendored asset + declared extensions, no WASM at all. + let htmx_runtime = is_htmx.then(|| { + view! { } + }); + let htmx_ext_scripts = is_htmx.then(|| { + htmx_extensions + .iter() + .map(|name| { + let src = format!("/assets/htmx/ext/{}.js", name); + view! { } + }) + .collect_view() + }); + let htmx_ext_attr = if is_htmx && !htmx_extensions.is_empty() { + htmx_extensions.join(",") + } else { + String::new() }; + let body_with_ext = (is_htmx && !htmx_ext_attr.is_empty()).then(|| htmx_ext_attr.clone()); view! { @@ -122,41 +208,42 @@ pub fn shell_with_path(_options: LeptosOptions, path: Option) -> impl In ", "")}> - // CSS includes based on asset mode (source vs bundled) {css_links} - - // JavaScript includes based on asset mode (source vs bundled) + {hydration_modulepreload} + {htmx_runtime} + {htmx_ext_scripts} {js_scripts} - + - - {app()} - + - + {hydration_bootstrap_script} + {hydration_module_init} } } -/// Legacy compatibility function +/// Legacy single-arg shell, defaults to the Leptos hydration profile. #[allow(dead_code)] -pub fn shell(options: LeptosOptions) -> impl IntoView { - shell_with_path(options, None) +pub fn shell(options: SiteOptions) -> impl IntoView { + shell_with_path(options, None, RenderingProfile::LeptosHydration, Vec::new()) } diff --git a/crates/foundation/crates/rustelo_server/src/startup/api.rs b/crates/foundation/crates/rustelo_server/src/startup/api.rs index d0c2481..d69cacd 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/api.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/api.rs @@ -6,10 +6,18 @@ use std::sync::Arc; -use axum::Router; +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; use rustelo_core_lib::cache::content::ContentCache; +use serde::Serialize; -use crate::api::{create_menu_routes, nav_test_router}; +#[cfg(feature = "leptos-hydration")] +use crate::api::activity_routes; +use crate::api::{create_i18n_routes, create_menu_routes, htmx_routes, nav_test_router}; #[cfg(feature = "auth")] use crate::auth::routes::create_auth_routes; use crate::config::Config; @@ -31,6 +39,7 @@ pub fn create_api_router( #[cfg(feature = "auth")] auth_service: Arc, #[cfg(feature = "content-db")] content_service: Arc, #[cfg(feature = "email")] email_service: Arc, + ws_server: Arc, ) -> Result> { let mut api_router = Router::new(); @@ -38,11 +47,74 @@ pub fn create_api_router( let menu_routes = create_menu_routes(); api_router = api_router.merge(menu_routes); + // Add i18n routes (always available — progressive FTL loading for WASM clients) + let i18n_routes = create_i18n_routes(); + api_router = api_router.merge(i18n_routes); + + // Activity download + HMAC callback — only available with leptos-hydration + // (uses OtpServiceExtension from rustelo_components_leptos). + #[cfg(feature = "leptos-hydration")] + { + api_router = api_router.merge(activity_routes()); + } + + // HTMX endpoints for theme toggle and language switch (htmx-ssr profile). + // Mounted unconditionally — the framework decides at request time whether + // the response is consumed by an htmx client. + api_router = api_router.nest("/htmx", htmx_routes()); + + // Cookie consent acceptance endpoint. + api_router = api_router.nest("/htmx/cookies", crate::api::htmx_cookies_routes()); + + // Per-field form validation endpoints. + api_router = api_router.nest("/htmx/validate", crate::api::htmx_validate_routes()); + + // Content grid fragment endpoint (tag filter, instant search, pagination). + api_router = api_router.nest("/htmx/content", crate::api::htmx_content_routes()); + + // SSE channel for toasts and live notifications. Publishers anywhere in + // the process call api::publish_toast(); subscribers connect via + // /api/htmx/notifications/sse. + api_router = api_router.nest("/htmx/notifications", crate::api::htmx_sse_routes()); + + // Per-post engagement fragments (views/ratings/messages). Stateless router; + // handlers read the engagement repository + rate limiter from the AppState + // request extension (layered in startup::middleware). + #[cfg(feature = "post-engagement")] + { + api_router = api_router.nest("/htmx/post", crate::api::htmx_post_routes()); + } + + // HTMX contact form (form-encoded → result fragment, honeypot + rate limit + + // best-effort email). Replaces the previously dead POST /api/contact target. + #[cfg(feature = "email")] + { + // Newsletter subscription, relayed server-side to the provider in + // newsletter.ncl. Gated on `email` like the other outbound-delivery + // endpoints: a build without it has no business posting a visitor's + // address anywhere. + api_router = api_router.nest("/htmx/newsletter", crate::api::htmx_newsletter_routes()); + api_router = api_router.nest("/htmx/contact", crate::api::htmx_contact_routes()); + // One endpoint for every declared form: POST /api/htmx/form/{id}, spec read at + // request time from site/config/forms/{id}.ncl. A new form is a .ncl + // and a template — no build. + api_router = api_router.nest("/htmx/form", crate::api::htmx_form_routes()); + } + // Add auth routes if feature is enabled #[cfg(feature = "auth")] { - let auth_routes = create_auth_routes().with_state(auth_service); + let auth_routes = create_auth_routes().with_state(auth_service.clone()); api_router = api_router.nest("/auth", auth_routes); + + // HTMX auth fragments. Shares the same AuthService so a single user + // model backs both surfaces. + let htmx_auth_routes = crate::api::htmx_auth_routes().with_state(auth_service.clone()); + api_router = api_router.nest("/htmx/auth", htmx_auth_routes); + + // HTMX user dashboard tabs + bookmarks/notes CRUD. + let htmx_user_routes = crate::api::htmx_user_routes().with_state(auth_service); + api_router = api_router.nest("/htmx/user", htmx_user_routes); } // Add content routes if feature is enabled @@ -155,5 +227,324 @@ pub fn create_api_router( } } + // Rendered HTML endpoint for WASM post viewer (no ContentLoader state needed) + api_router = api_router.route( + "/content-render/{content_type}/{language}/{slug}", + get(render_content_for_wasm), + ); + + // WebSocket endpoint + toast broadcast endpoint, both sharing ws_server state. + let ws_route = Router::new() + .route( + "/ws", + get(crate::content::websocket::WebSocketServer::handle_upgrade), + ) + .route("/notify", post(notify_broadcast)) + .with_state(ws_server); + api_router = api_router.merge(ws_route); + Ok(api_router) } + +// ── /api/notify ────────────────────────────────────────────────────────────── + +#[derive(serde::Deserialize)] +struct NotifyPayload { + kind: String, + message: String, + /// Optional target user UUID. When set the message is persisted to + /// `user_messages` in addition to being broadcast over the WebSocket. + #[allow(dead_code)] + user_id: Option, + /// Subject line for persistent messages (defaults to `kind` when omitted). + #[allow(dead_code)] + subject: Option, + /// Sender label shown in the user's inbox (defaults to "system"). + #[allow(dead_code)] + sender_email: Option, +} + +/// POST /api/notify — broadcast a toast to all connected WebSocket clients. +/// +/// Two authentication paths are accepted: +/// 1. **JWT** (`Authorization: Bearer `) — role must be `admin` or +/// `moderator` (enforced by RBAC before this handler runs, verified again +/// in-handler as belt-and-suspenders). +/// 2. **Service token** (`X-Service-Token: `) — validated against +/// the `service_tokens` table. Allows external services (CI/CD, monitoring) +/// to send notifications without a full user account. +/// +/// When `user_id` is present the message is also persisted to `user_messages` +/// so it survives the WebSocket session. +/// +/// ```text +/// # JWT path +/// curl -s -X POST http://localhost:3000/api/notify \ +/// -H "Authorization: Bearer " \ +/// -H "Content-Type: application/json" \ +/// -d '{"kind":"info","message":"Hello","user_id":""}' +/// +/// # Service token path +/// curl -s -X POST http://localhost:3000/api/notify \ +/// -H "X-Service-Token: " \ +/// -H "Content-Type: application/json" \ +/// -d '{"kind":"warning","message":"Deploy started"}' +/// ``` +#[cfg(feature = "auth")] +async fn notify_broadcast( + headers: axum::http::HeaderMap, + axum::Extension(auth_ctx): axum::Extension, + axum::Extension(app_state): axum::Extension, + State(ws): State>, + Json(payload): Json, +) -> StatusCode { + use rustelo_core_lib::auth::Role; + use sha2::{Digest, Sha256}; + + // ── Authorisation ───────────────────────────────────────────────────────── + // Path 1: JWT — admin, moderator, or supervisor role. + let jwt_authorised = auth_ctx.user.as_ref().map_or(false, |u| { + u.has_role(&Role::Admin) + || u.has_role(&Role::Moderator) + || u.has_role(&Role::Custom("supervisor".to_string())) + }); + + // Path 2: service token via X-Service-Token header. + let service_authorised = if !jwt_authorised { + if let Some(raw) = headers.get("X-Service-Token").and_then(|v| v.to_str().ok()) { + let hash = Sha256::digest(raw.as_bytes()) + .iter() + .fold(String::new(), |mut s, b| { + use std::fmt::Write; + let _ = write!(s, "{b:02x}"); + s + }); + app_state + .auth_repository + .validate_service_token(&hash, "notify") + .await + .ok() + .flatten() + .is_some() + } else { + false + } + } else { + false + }; + + if !jwt_authorised && !service_authorised { + return StatusCode::UNAUTHORIZED; + } + + // ── Persistence (optional) ──────────────────────────────────────────────── + if let Some(ref uid_str) = payload.user_id { + if let Ok(user_uuid) = uuid::Uuid::parse_str(uid_str) { + let sender = payload + .sender_email + .as_deref() + .unwrap_or("system") + .to_owned(); + let subject = payload + .subject + .as_deref() + .unwrap_or(payload.kind.as_str()) + .to_owned(); + if let Err(e) = app_state + .auth_repository + .insert_message(user_uuid, &sender, &subject, &payload.message) + .await + { + tracing::warn!(error = %e, "notify_broadcast: failed to persist message"); + } + } + } + + // ── Broadcast ───────────────────────────────────────────────────────────── + ws.broadcast_notification( + crate::content::websocket::ContentNotification::ToastBroadcast { + kind: payload.kind, + message: payload.message, + }, + ) + .await; + StatusCode::OK +} + +#[cfg(not(feature = "auth"))] +async fn notify_broadcast( + State(ws): State>, + Json(payload): Json, +) -> StatusCode { + ws.broadcast_notification( + crate::content::websocket::ContentNotification::ToastBroadcast { + kind: payload.kind, + message: payload.message, + }, + ) + .await; + StatusCode::OK +} + +/// Rendered content response for WASM post viewer. +/// `id` is the stable filesystem identifier (same across all languages). +/// `category` is the primary category, invariant across languages. +/// `translation_slugs` maps language code → localized URL slug of the +/// translation. `translation_ids` maps language code → stable id of the +/// translation. The WASM client uses these for language switching without +/// additional fetches. +#[derive(Debug, Serialize)] +struct RenderedContent { + title: String, + subtitle: String, + html: String, + /// Stable content identifier — same filename stem across all languages. + id: String, + /// Primary category — invariant across languages. + category: String, + /// lang → localized slug for the translation. + translation_slugs: std::collections::HashMap, + /// lang → stable id for the translation (same as `id` in most cases). + translation_ids: std::collections::HashMap, +} + +/// Handler: reads markdown by slug, returns rendered HTML + metadata as JSON. +/// Used by the WASM PostViewer via `create_resource` instead of DOM +/// manipulation. +async fn render_content_for_wasm( + Path((content_type, language, slug)): Path<(String, String, String)>, +) -> Result, (StatusCode, String)> { + use rustelo_core_lib::fluent::{ + load_content_by_slug, load_content_index, process_markdown_file, + rewrite_content_image_paths, + }; + + // Use the registered ContentKindRegistry (populated from content-kinds.toml at + // startup), not get_enabled_content_types() which always returns the + // compile-time fallback list. + let registry = rustelo_core_lib::create_content_kind_registry(); + let valid_types: Vec = if registry.kinds.is_empty() { + rustelo_core_lib::get_enabled_content_types() + } else { + registry.kinds.keys().cloned().collect() + }; + if !valid_types.contains(&content_type) { + return Err(( + StatusCode::BAD_REQUEST, + format!( + "Invalid content type '{}'. Valid: {}", + content_type, + valid_types.join(", ") + ), + )); + } + + // Load index to extract the stable item id and primary category. + // id is the same across all languages (filename stem). + // category is invariant: the WASM client uses these to switch languages without + // extra fetches. + // + // Cross-language resolution: if slug is not found in the requested language's + // index (e.g., the URL still carries the previous language's slug after a + // fast language switch), search all other language indexes. Once the + // stable `id` is found, use it to load the correct file in the target + // language. + let local_item = load_content_index(&content_type, &language) + .ok() + .and_then(|idx| { + idx.items + .into_iter() + .find(|item| item.slug == slug || item.id == slug) + }); + + let (item_id, item_category, item_translation_slugs, item_translation_ids) = + if let Some(ref item) = local_item { + let cat = item.categories.first().cloned().unwrap_or_default(); + ( + item.id.clone(), + cat, + item.translation_slugs.clone(), + item.translation_ids.clone(), + ) + } else { + // Slug not found locally — walk other language indexes to resolve the stable + // id. + let resolved = rustelo_core_lib::i18n::discover_available_languages() + .iter() + .filter(|l| l.as_str() != language) + .find_map(|lang| { + let idx = load_content_index(&content_type, lang).ok()?; + idx.items + .into_iter() + .find(|item| item.slug == slug || item.id == slug) + .map(|item| item.id) + }); + match resolved { + Some(id) => { + // Re-query the target-language index with the stable id to get category + // and translations. + let (cat, t_slugs, t_ids) = load_content_index(&content_type, &language) + .ok() + .and_then(|idx| { + idx.items + .into_iter() + .find(|item| item.id == id) + .map(|item| { + ( + item.categories.into_iter().next().unwrap_or_default(), + item.translation_slugs, + item.translation_ids, + ) + }) + }) + .unwrap_or_default(); + (id, cat, t_slugs, t_ids) + } + None => ( + slug.clone(), + String::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + ), + } + }; + + // Use the resolved stable id so load_content_by_slug can locate the file + // even when the original URL slug belongs to a different language. + let effective_slug = if item_id != slug { &item_id } else { &slug }; + let markdown = load_content_by_slug(effective_slug, &content_type, &language) + .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + + let (metadata, html) = process_markdown_file(&markdown) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let html = rewrite_content_image_paths(&html, &content_type, &language, &slug); + + let title = metadata.title.unwrap_or_else(|| { + slug.replace('-', " ") + .split_whitespace() + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => { + let upper: String = first.to_uppercase().collect(); + let lower: String = chars.collect::().to_lowercase(); + upper + lower.as_str() + } + } + }) + .collect::>() + .join(" ") + }); + + Ok(Json(RenderedContent { + title, + subtitle: metadata.subtitle.unwrap_or_default(), + html, + id: item_id, + category: item_category, + translation_slugs: item_translation_slugs, + translation_ids: item_translation_ids, + })) +} diff --git a/crates/foundation/crates/rustelo_server/src/startup/auth.rs b/crates/foundation/crates/rustelo_server/src/startup/auth.rs index 17428e3..729508f 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/auth.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/auth.rs @@ -1,20 +1,40 @@ //! Authentication services initialization //! //! This module handles the initialization of all authentication-related -//! services including JWT, OAuth, password handling, and two-factor -//! authentication. +//! services including JWT, OAuth, password handling, two-factor, and OTP. +#[cfg(feature = "auth")] +use std::sync::Arc; + +#[cfg(feature = "auth")] +use crate::auth::otp::OtpService; +#[cfg(all(feature = "auth", feature = "email"))] +use crate::auth::otp_store::{MemoryOtpStore, OtpStore}; #[cfg(feature = "auth")] use crate::auth::{AuthService, JwtService, OAuthService, PasswordService, TwoFactorService}; +#[cfg(feature = "auth")] +use crate::config::load_auth_otp_config; use crate::config::Config; +#[cfg(all(feature = "auth", feature = "email"))] +use crate::config::OtpStoreKind; +#[cfg(feature = "auth")] +use crate::database::auth::AuthRepositoryTrait; #[cfg(any(feature = "auth", feature = "content-db"))] use crate::database::DatabasePool; +#[cfg(all(feature = "auth", feature = "email"))] +use crate::email::EmailService; -/// Initialize authentication services +/// Initialize authentication services. +/// +/// `email_service` is `Some` when the `email` feature is enabled and the +/// caller has already constructed an `EmailService` via +/// `initialize_email_service`. When present, the OTP service is activated +/// automatically. #[cfg(feature = "auth")] pub async fn initialize_auth_services( database_pool: &DatabasePool, config: &Config, + #[cfg(feature = "email")] email_service: Option>, ) -> Result< ( Arc, @@ -23,18 +43,16 @@ pub async fn initialize_auth_services( ), Box, > { - // Use the new database-agnostic auth repository - let auth_repository = Arc::new(crate::database::auth::AuthRepository::from_pool( - database_pool, - )); - let jwt_service = Arc::new(JwtService::new().map_err(|e| { - tracing::error!("Failed to create JWT service: {}", e); - std::process::exit(1); - })?); - let oauth_service = Arc::new(OAuthService::new().map_err(|e| { - tracing::error!("Failed to create OAuth service: {}", e); - std::process::exit(1); - })?); + let jwt_service = Arc::new( + JwtService::new() + .inspect_err(|e| tracing::error!("Failed to create JWT service: {}", e))?, + ); + let oauth_service = Arc::new(OAuthService::new().map_err( + |e| -> Box { + tracing::error!("Failed to create OAuth service: {}", e); + std::io::Error::new(std::io::ErrorKind::Other, e.to_string()).into() + }, + )?); let password_service = Arc::new(PasswordService::new()); let two_factor_service = Arc::new(TwoFactorService::from_pool( database_pool, @@ -42,15 +60,48 @@ pub async fn initialize_auth_services( format!("{} Authentication", config.app.name), )); + // Load OTP config from site/config/auth.toml (defaults on missing file). + let auth_toml_path = std::path::Path::new(&config.root_path).join("site/config/auth.toml"); + let otp_config = load_auth_otp_config(&auth_toml_path); + + let auth_repository = Arc::new( + crate::database::auth::AuthRepository::from_pool(database_pool) + .with_registration_role(otp_config.registration_role.clone()), + ); + + // Build OTP service when the email feature is enabled and an EmailService + // was provided. + #[cfg(feature = "email")] + let otp_service: Option> = email_service.map(|email_svc| { + let store: Arc = match otp_config.otp_store { + OtpStoreKind::Memory => MemoryOtpStore::new_arc(), + OtpStoreKind::Db => { + tracing::warn!("OTP db store not yet implemented; using memory store"); + MemoryOtpStore::new_arc() + } + }; + Arc::new(OtpService { + store, + password_svc: password_service.clone(), + email_svc, + auth_repo: auth_repository.clone(), + jwt_svc: jwt_service.clone(), + }) + }); + + #[cfg(not(feature = "email"))] + let otp_service: Option> = None; + let auth_service = Arc::new(AuthService::new( jwt_service.clone(), oauth_service.clone(), password_service.clone(), auth_repository.clone(), two_factor_service.clone(), + otp_service, + otp_config, )); - // Initialize database tables if let Err(e) = auth_repository.init_tables().await { tracing::error!("Failed to initialize database: {}", e); std::process::exit(1); diff --git a/crates/foundation/crates/rustelo_server/src/startup/cache.rs b/crates/foundation/crates/rustelo_server/src/startup/cache.rs index 6712345..84b0dae 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/cache.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/cache.rs @@ -54,7 +54,7 @@ pub async fn initialize_cache_system( // Initialize content cache system with persistent storage (without Leptos // reactive features for server) - tracing::info!("Initializing content cache system..."); + tracing::debug!("Initializing content cache system..."); let content_cache = Arc::new(ContentCache::with_config_and_cache_dir( shared_cache_config.clone(), cache_dir.clone(), @@ -62,15 +62,15 @@ pub async fn initialize_cache_system( // Warm up cache with essential content if prewarming is enabled if shared_cache_config.enable_prewarming { - tracing::info!("Starting cache prewarming..."); + tracing::debug!("Starting cache prewarming..."); let cache_warmer = Arc::new(rustelo_core_lib::cache::content::CacheWarmer::new()); - tokio::task::spawn_local({ + tokio::spawn({ let cache_warmer = cache_warmer.clone(); let content_cache_clone = content_cache.clone(); async move { content_cache_clone.preload_for_languages(vec!["en".to_string(), "es".to_string()]); cache_warmer.warm_essential_content().await; - tracing::info!("Cache prewarming completed successfully"); + tracing::debug!("Cache prewarming completed successfully"); } }); } @@ -80,7 +80,7 @@ pub async fn initialize_cache_system( let cleanup_interval = std::time::Duration::from_secs(config.cache.persistent_cleanup_interval); let content_cache_cleanup = content_cache.clone(); - tokio::task::spawn_local(async move { + tokio::spawn(async move { let mut interval = tokio::time::interval(cleanup_interval); loop { interval.tick().await; diff --git a/crates/foundation/crates/rustelo_server/src/startup/database.rs b/crates/foundation/crates/rustelo_server/src/startup/database.rs index b9c2168..3301c52 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/database.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/database.rs @@ -3,11 +3,13 @@ //! This module handles database connection setup, including support for both //! PostgreSQL and SQLite databases with automatic type detection. -#[cfg(any(feature = "auth", feature = "content-db"))] +#[cfg(any(feature = "auth", feature = "content-db", feature = "post-engagement"))] +use crate::config::Config; +#[cfg(any(feature = "auth", feature = "content-db", feature = "post-engagement"))] use crate::database::{Database, DatabaseConfig, DatabasePool}; /// Initialize database connection (if database features are enabled) -#[cfg(any(feature = "auth", feature = "content-db"))] +#[cfg(any(feature = "auth", feature = "content-db", feature = "post-engagement"))] pub async fn initialize_database( config: &Config, ) -> Result<(Database, sqlx::PgPool, DatabasePool), Box> { @@ -18,35 +20,43 @@ pub async fn initialize_database( connect_timeout: std::time::Duration::from_secs(config.database.connect_timeout), idle_timeout: std::time::Duration::from_secs(config.database.idle_timeout), max_lifetime: std::time::Duration::from_secs(config.database.max_lifetime), + create_if_missing: config.database.create_if_missing, }; tracing::info!("Connecting to database: {}", database_config.url); - let database_pool = DatabasePool::new(&database_config).await.map_err(|e| { - tracing::error!("Failed to connect to database: {}", e); - std::process::exit(1); - })?; + let database_pool = + DatabasePool::new(&database_config) + .await + .map_err(|e| -> Box { + tracing::error!("Failed to connect to database: {}", e); + std::io::Error::new(std::io::ErrorKind::Other, e.to_string()).into() + })?; let db_type = database_pool.database_type(); tracing::info!("Database type detected: {:?}", db_type); let database = Database::new(database_pool.clone()); - // For backward compatibility with existing code that expects PgPool + // Auth services use the database-agnostic DatabasePool directly. + // PgPool is only needed when actually connecting to PostgreSQL; for SQLite + // we return a None-sentinel via a disconnected pool stub rather than exiting. #[cfg(feature = "auth")] let pool = match &database_pool { DatabasePool::PostgreSQL(pg_pool) => pg_pool.clone(), DatabasePool::SQLite(_) => { - tracing::info!("Using SQLite database with database-agnostic auth system"); - // Create a dummy PgPool for compatibility (will be replaced gradually) + tracing::info!("SQLite database: auth services use database-agnostic pool"); + // Return a disconnected stub — callers that receive this pool and try to + // use it directly will fail fast with a clear error rather than at exit. sqlx::postgres::PgPoolOptions::new() .max_connections(1) - .connect("postgres://dummy:dummy@localhost/dummy") - .await + .connect_lazy("postgres://127.0.0.1/rustelo_stub") .unwrap_or_else(|_| { - tracing::warn!("Could not create dummy PostgreSQL pool for compatibility"); - tracing::info!("Auth services will use database-agnostic implementation"); - std::process::exit(0); // Exit gracefully for now + // connect_lazy rarely fails; if it does return a pool that errors on use + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://127.0.0.1/stub") + .expect("failed to build lazy stub pool") }) } }; @@ -54,14 +64,15 @@ pub async fn initialize_database( #[cfg(not(feature = "auth"))] let pool = match &database_pool { DatabasePool::PostgreSQL(pg_pool) => pg_pool.clone(), - DatabasePool::SQLite(_) => { - // Create a dummy pool that won't be used - sqlx::postgres::PgPoolOptions::new() - .max_connections(1) - .connect("postgres://dummy:dummy@localhost/dummy") - .await - .unwrap_or_else(|_| std::process::exit(1)) - } + DatabasePool::SQLite(_) => sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://127.0.0.1/rustelo_stub") + .unwrap_or_else(|_| { + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://127.0.0.1/stub") + .expect("failed to build lazy stub pool") + }), }; Ok((database, pool, database_pool)) diff --git a/crates/foundation/crates/rustelo_server/src/startup/email.rs b/crates/foundation/crates/rustelo_server/src/startup/email.rs index c58d5f3..e7a58d7 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/email.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/email.rs @@ -3,6 +3,11 @@ //! This module handles the initialization of email services with support for //! multiple providers (SMTP, SendGrid, Console) and template management. +#[cfg(any(feature = "email", feature = "metrics"))] +use std::sync::Arc; + +#[cfg(any(feature = "email", feature = "metrics"))] +use crate::config::Config; #[cfg(feature = "email")] use crate::email::{EmailService, EmailServiceBuilder}; @@ -51,10 +56,14 @@ pub async fn initialize_email_service( } }; - let email_service = email_service_builder.build().await.map_err(|e| { - tracing::error!("Failed to initialize email service: {}", e); - std::process::exit(1); - })?; + let email_service = + email_service_builder + .build() + .await + .map_err(|e| -> Box { + tracing::error!("Failed to initialize email service: {}", e); + Box::new(e) + })?; tracing::info!( "Email service initialized with provider: {}", diff --git a/crates/foundation/crates/rustelo_server/src/startup/handlers.rs b/crates/foundation/crates/rustelo_server/src/startup/handlers.rs index 6168763..6852d34 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/handlers.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/handlers.rs @@ -5,19 +5,21 @@ use axum::{ extract::State, - http::HeaderMap, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use leptos::prelude::*; use rustelo_core_lib::routing::engine::resolve_route; -use crate::run::{render_ssr_with_context, render_ssr_with_route_resolution}; +use crate::run::SiteOptions; +use crate::run::{render_route_response, render_ssr_with_context}; +use crate::utils::htmx::{htmx_error_response, is_htmx_request}; -/// Fallback handler for dynamic routes (blog posts, etc.) -/// Uses the shared routing engine to resolve routes and render appropriate -/// responses +/// Fallback handler for dynamic routes (blog posts, etc.). +/// +/// Resolves the URL, applies language-redirect rules, then dispatches on +/// `(rendering_profile, HX-Request)` via [`render_route_response`]. pub async fn fallback_handler( - State(leptos_options): State, + State(site_options): State, headers: HeaderMap, uri: axum::http::Uri, ) -> Response { @@ -25,14 +27,11 @@ pub async fn fallback_handler( tracing::debug!("Fallback handler processing path: {}", path); - // Use the shared routing engine to resolve the route let route_resolution = resolve_route(path); if route_resolution.exists { - // Route exists - detect language and handle redirects if needed let detected_lang = crate::utils::lang_detection::detect_ssr_language(path, &headers); - // Check if we need to redirect to the localized version (language-agnostic) if let Some(localized_url) = crate::utils::lang_detection::check_language_redirect(path, &detected_lang, &headers) { @@ -42,50 +41,49 @@ pub async fn fallback_handler( detected_lang, localized_url ); - return axum::response::Response::builder() - .status(302) // Temporary redirect + // HTMX clients honour HX-Redirect for client-side navigation; everyone else + // gets the standard 302 Location. + let mut builder = axum::response::Response::builder().status(302); + if is_htmx_request(&headers) { + builder = builder.header("HX-Redirect", &localized_url); + } + return builder .header("Location", localized_url) .body(axum::body::Body::empty()) .unwrap() .into_response(); } - // Route exists - render it using the routing engine's resolution - tracing::debug!("Route exists, rendering with SSR: {}", path); - - // Check if the routing engine provided a specific component resolution - if let Some(component) = &route_resolution.component { - tracing::debug!( - "Using routing engine component: {:?} with parameters: {:?}", - component, - route_resolution.parameters - ); - - // Create a custom SSR render that respects the routing engine's decision - axum::response::Html(render_ssr_with_route_resolution( - leptos_options, - &route_resolution, - )) - .into_response() - } else { - // Fallback to standard Leptos SSR routing - tracing::debug!( - "No specific component from routing engine, using standard SSR: {}", - path - ); - axum::response::Html(render_ssr_with_context( - leptos_options, - Some(path.to_string()), - )) - .into_response() + tracing::debug!( + "Route exists, rendering with profile {} for path {}", + route_resolution.rendering_profile, + path + ); + // Inject the raw query string as a synthetic header so shell renderers + // (e.g. the HTMX grid) can read filter/search params from direct URLs + // like /blog?tag=rust&q=nush without requiring a framework API change. + let mut augmented = headers.clone(); + if let Some(query) = uri.query() { + if let Ok(value) = axum::http::HeaderValue::from_str(query) { + augmented.insert( + axum::http::HeaderName::from_static("x-request-query"), + value, + ); + } } + render_route_response(site_options, &route_resolution, &augmented) } else { - // Route doesn't exist - return 404 tracing::debug!("Route not found: {}", path); - // Create a proper 404 page using Leptos SSR with proper arena context - let not_found_html = - render_ssr_with_context(leptos_options, Some("/not-found".to_string())); + if is_htmx_request(&headers) { + return htmx_error_response( + &headers, + StatusCode::NOT_FOUND, + format!("Not found: {}", path), + ); + } + + let not_found_html = render_ssr_with_context(site_options, Some("/not-found".to_string())); axum::response::Response::builder() .status(404) diff --git a/crates/foundation/crates/rustelo_server/src/startup/middleware.rs b/crates/foundation/crates/rustelo_server/src/startup/middleware.rs index f256c63..f2dd7b8 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/middleware.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/middleware.rs @@ -24,6 +24,45 @@ pub fn apply_middleware_layers( #[cfg(feature = "auth")] jwt_service: Arc, #[cfg(feature = "auth")] auth_repository: Arc, ) -> axum::Router { + // Insert AppState as a request extension so Leptos server functions can access + // it via extract::>() regardless of the outer Axum + // state type. + app = app.layer(axum::Extension(app_state.clone())); + + // Register Leptos server-function facades. Only needed when leptos-hydration + // is active — htmx-ssr uses conventional Axum endpoints for OTP and contact + // form submissions instead of Leptos server functions. + #[cfg(all(feature = "auth", feature = "leptos-hydration"))] + if let Some(otp_svc) = &app_state.auth_service.otp_service { + use rustelo_components_leptos::auth::{OtpServiceExtension, OtpServiceFacade}; + + use crate::auth::OtpServiceAdapter; + let adapter = OtpServiceAdapter { + otp_service: otp_svc.clone(), + config: app_state.auth_service.otp_config.clone(), + ws_server: Some(app_state.ws_server.clone()), + }; + app = app.layer(axum::Extension(OtpServiceExtension( + Arc::new(adapter) as Arc + ))); + } + + #[cfg(all(feature = "email", feature = "leptos-hydration"))] + { + use std::sync::Arc; + + use rustelo_components_leptos::{ContactFormExtension, ContactFormFacade}; + + use crate::email::ContactFormAdapter; + let recipient = app_state.email_service.get_config().default_from.clone(); + let adapter = ContactFormAdapter { + email_service: app_state.email_service.clone(), + recipient, + }; + app = app.layer(axum::Extension(ContactFormExtension( + Arc::new(adapter) as Arc + ))); + } // Add metrics middleware if feature is enabled #[cfg(feature = "metrics")] { @@ -35,15 +74,28 @@ pub fn apply_middleware_layers( } } - // Add auth middleware if feature is enabled + // Add file-based RBAC middleware (must run AFTER auth injects AuthContext). + // Layers are applied inside-out: auth is outer (runs first), RBAC is inner. + // So RBAC must be added to the router BEFORE auth in this function. + #[cfg(feature = "rbac")] + { + app = app.layer(axum::middleware::from_fn_with_state( + app_state.file_rbac_config.clone(), + crate::rbac::ncl_middleware::file_rbac_middleware, + )); + } + + // Add auth middleware if feature is enabled. + // CookieManagerLayer MUST be the outer layer (applied last, runs first) so that + // cookies are parsed before auth_middleware tries to extract them. #[cfg(feature = "auth")] { app = app - .layer(CookieManagerLayer::new()) .layer(axum::middleware::from_fn_with_state( (jwt_service, auth_repository), auth_middleware, - )); + )) + .layer(CookieManagerLayer::new()); } // Add security headers @@ -71,7 +123,8 @@ pub async fn dev_cache_middleware(request: Request, next: Next) -> Response { let mut response = next.run(request).await; // Set proper MIME types for static files - if path.starts_with("/public/") || path.starts_with("/styles/") || path.starts_with("/logos/") { + if path.starts_with("/public/") || path.starts_with("/styles/") || path.starts_with("/images/") + { let headers = response.headers_mut(); // Set proper content-type based on file extension diff --git a/crates/foundation/crates/rustelo_server/src/startup/mod.rs b/crates/foundation/crates/rustelo_server/src/startup/mod.rs index 3679c0b..6763200 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/mod.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/mod.rs @@ -38,6 +38,8 @@ pub mod database; pub mod email; pub mod handlers; pub mod middleware; +#[cfg(feature = "nats-admin")] +pub mod nats; pub mod router; pub mod server; @@ -49,7 +51,7 @@ pub use auth::initialize_security_components; pub use cache::initialize_cache_system; #[cfg(feature = "content-db")] pub use database::initialize_content_services; -#[cfg(any(feature = "auth", feature = "content-db"))] +#[cfg(any(feature = "auth", feature = "content-db", feature = "post-engagement"))] pub use database::initialize_database; #[cfg(feature = "email")] pub use email::initialize_email_service; @@ -60,5 +62,5 @@ pub use router::create_main_router; // Re-export startup functions from server module pub use server::{ initialize_navigation_tracking, load_and_validate_config, setup_environment, - setup_leptos_config, setup_logging_and_panic_handling, start_server, + setup_logging_and_panic_handling, setup_site_options, start_server, }; diff --git a/crates/foundation/crates/rustelo_server/src/startup/nats.rs b/crates/foundation/crates/rustelo_server/src/startup/nats.rs new file mode 100644 index 0000000..0e2f43d --- /dev/null +++ b/crates/foundation/crates/rustelo_server/src/startup/nats.rs @@ -0,0 +1,401 @@ +//! NATS admin integration startup — connects, initialises the log ring buffer, +//! and spawns background subscriber tasks for health-check and log-fetch +//! request-reply subjects. +//! +//! Enabled only when `nats-admin` feature is active. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use serde_json::json; +use tracing::{error, info, warn}; +use tracing_subscriber::Layer; + +use crate::config::NatsAdminConnectionConfig; +use crate::nats::executor::{execute_pipeline, PipelineContext}; +use crate::nats::pipeline::PipelineTrigger; +use crate::nats::{NatsAdminClient, Pipeline}; + +/// Maximum number of log lines kept in the ring buffer. +const LOG_RING_CAPACITY: usize = 2_000; + +/// Thread-safe ring buffer for recent structured log lines. +/// +/// Populated by [`NatsLogLayer`] at the tracing layer level; drained on NATS +/// admin log requests. +pub type LogBuffer = Arc>>; + +/// A `tracing_subscriber` layer that captures formatted log lines into +/// the shared ring buffer. +/// +/// Install via `tracing_subscriber::registry().with(NatsLogLayer::new(buf))`. +pub struct NatsLogLayer { + buf: LogBuffer, +} + +impl NatsLogLayer { + pub fn new(buf: LogBuffer) -> Self { + Self { buf } + } +} + +impl Layer for NatsLogLayer +where + S: tracing::Subscriber, +{ + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + // Format: level target message (lightweight, no allocation on fast path + // unless the ring buffer is not full yet) + let meta = event.metadata(); + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + + let line = format!("[{}] {} — {}", meta.level(), meta.target(), visitor.0,); + + if let Ok(mut buf) = self.buf.lock() { + if buf.len() >= LOG_RING_CAPACITY { + buf.pop_front(); + } + buf.push_back(line); + } + } +} + +struct MessageVisitor(String); + +impl tracing::field::Visit for MessageVisitor { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "message" { + self.0 = value.to_owned(); + } + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{value:?}"); + } + } +} + +/// Connect to NATS and return `(client, log_buffer)`. +/// +/// Returns `Ok(None)` when `cfg.enabled` is `false` — callers should skip +/// subscriber spawning and leave the `AppState` field as `None`. +pub async fn initialize_nats_admin( + cfg: &NatsAdminConnectionConfig, +) -> Result, crate::nats::client::ConnectError> { + if !cfg.enabled { + info!("nats-admin: disabled in config, skipping"); + return Ok(None); + } + + let client = NatsAdminClient::connect(cfg).await?; + let log_buffer: LogBuffer = Arc::new(Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY))); + Ok(Some((client, log_buffer))) +} + +/// Spawn background subscriber tasks for the admin subjects. +/// +/// - `admin.health` — request-reply health ping (responds with a JSON status +/// blob). +/// - `admin.logs` — request-reply recent logs fetch (returns the last N lines +/// from the ring buffer as a JSON array). +/// +/// Both tasks run indefinitely; errors are logged and the task restarts the +/// loop rather than terminating. +pub fn spawn_nats_admin_subscribers(client: NatsAdminClient, log_buffer: LogBuffer) { + let client_health = client.clone(); + let client_logs = client.clone(); + let buf_logs = Arc::clone(&log_buffer); + + tokio::spawn(async move { + run_health_subscriber(client_health).await; + }); + + tokio::spawn(async move { + run_logs_subscriber(client_logs, buf_logs).await; + }); +} + +async fn run_health_subscriber(client: NatsAdminClient) { + let mut sub = match client.subscribe("health").await { + Ok(s) => s, + Err(e) => { + error!(error = %e, "nats-admin: failed to subscribe to health subject"); + return; + } + }; + + info!("nats-admin: health subscriber ready"); + + while let Some(msg) = sub.next().await { + let Some(reply) = msg.reply else { + warn!("nats-admin: health request without reply-to, skipping"); + continue; + }; + + let hostname = std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".into()); + let payload = json!({ + "status": "ok", + "host": hostname, + "version": env!("CARGO_PKG_VERSION"), + }); + + let bytes = match serde_json::to_vec(&payload) { + Ok(b) => b, + Err(e) => { + error!(error = %e, "nats-admin: health response serialisation failed"); + continue; + } + }; + + if let Err(e) = client.inner().publish(reply, bytes.into()).await { + error!(error = %e, "nats-admin: failed to publish health reply"); + } + } + + warn!("nats-admin: health subscriber stream ended"); +} + +async fn run_logs_subscriber(client: NatsAdminClient, buf: LogBuffer) { + let mut sub = match client.subscribe("logs").await { + Ok(s) => s, + Err(e) => { + error!(error = %e, "nats-admin: failed to subscribe to logs subject"); + return; + } + }; + + info!("nats-admin: logs subscriber ready"); + + while let Some(msg) = sub.next().await { + let Some(reply) = msg.reply else { + warn!("nats-admin: logs request without reply-to, skipping"); + continue; + }; + + // Optional payload: `{"lines": N}` — defaults to 100 most recent. + let requested: usize = serde_json::from_slice::(&msg.payload) + .ok() + .and_then(|v| v.get("lines").and_then(|n| n.as_u64())) + .unwrap_or(100) as usize; + + let lines: Vec = buf + .lock() + .map(|guard| { + guard + .iter() + .rev() + .take(requested) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect() + }) + .unwrap_or_default(); + + let payload = json!({ "lines": lines, "count": lines.len() }); + + let bytes = match serde_json::to_vec(&payload) { + Ok(b) => b, + Err(e) => { + error!(error = %e, "nats-admin: logs response serialisation failed"); + continue; + } + }; + + if let Err(e) = client.inner().publish(reply, bytes.into()).await { + error!(error = %e, "nats-admin: failed to publish logs reply"); + } + } + + warn!("nats-admin: logs subscriber stream ended"); +} + +// ── Config-driven pipeline subscribers ─────────────────────────────────────── + +/// Spawn one background task per configured NATS pipeline. +/// +/// When the client has an `EventStream` (JetStream enabled), pipelines use the +/// durable pull consumer — messages survive server restarts and require +/// explicit ack. When JetStream is unavailable, falls back to core NATS push +/// subscriptions. +/// +/// Returns the `JoinHandle` for each spawned task so callers can abort them +/// on hot-reload. CI/CD pipelines are logged and skipped — their runtime +/// wiring lives outside the NATS subscriber loop. +pub fn spawn_pipeline_subscribers( + client: NatsAdminClient, + pipelines: Vec, + ctx: Arc, +) -> Vec> { + if pipelines.is_empty() { + info!("pipelines: none configured, skipping"); + return Vec::new(); + } + + let mut handles = Vec::new(); + + for pipeline in pipelines { + match &pipeline.trigger { + PipelineTrigger::Nats { subject } => { + let subject = subject.clone(); + let client = client.clone(); + let ctx = Arc::clone(&ctx); + handles.push(tokio::spawn(async move { + run_nats_pipeline_subscriber(client, pipeline, subject, ctx).await; + })); + } + PipelineTrigger::CiCd { event } => { + info!( + pipeline = %pipeline.name, + event = %event, + "pipelines: ci_cd pipeline registered (webhook wiring pending)" + ); + } + } + } + + handles +} + +/// Run a single pipeline subscriber loop. +/// +/// Uses JetStream pull consumer (`EventStream`) when available — messages +/// are durable, survive restarts, and require explicit ack. +/// +/// Falls back to core NATS push subscription when JetStream is disabled, +/// filtering by the configured subject pattern. +async fn run_nats_pipeline_subscriber( + client: NatsAdminClient, + pipeline: Pipeline, + subject: String, + ctx: Arc, +) { + if let Some(event_stream) = client.event_stream() { + run_jetstream_pipeline_subscriber(event_stream, pipeline, subject, ctx).await; + } else { + run_core_nats_pipeline_subscriber(client, pipeline, subject, ctx).await; + } +} + +/// JetStream pull consumer loop — durable, ack-based. +/// +/// Pulls messages in batches of 10 using `pull_batch()` and executes the +/// pipeline for each message. Filter: only processes messages whose subject +/// matches the configured pipeline subject (exact or wildcard prefix). +async fn run_jetstream_pipeline_subscriber( + event_stream: std::sync::Arc, + pipeline: Pipeline, + subject: String, + ctx: Arc, +) { + info!( + pipeline = %pipeline.name, + subject = %subject, + "pipelines: JetStream subscriber ready" + ); + + loop { + match event_stream.pull_batch(10).await { + Ok(messages) => { + for (msg_subject, payload, js_msg) in messages { + if !subject_matches(&subject, &msg_subject) { + // Message belongs to a different pipeline — nack so + // another consumer (or future retry) can process it. + let _ = js_msg.ack().await; + continue; + } + + execute_pipeline(&pipeline, &msg_subject, &payload, &ctx).await; + + if let Err(e) = js_msg.ack().await { + error!( + pipeline = %pipeline.name, + subject = %msg_subject, + error = %e, + "pipelines: JetStream ack failed" + ); + } + } + } + Err(e) => { + error!( + pipeline = %pipeline.name, + error = %e, + "pipelines: JetStream pull_batch failed, retrying" + ); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + } +} + +/// Core NATS push subscription fallback (when JetStream is disabled). +/// +/// Uses raw subject subscription — messages are fire-and-forget (no +/// durability). +async fn run_core_nats_pipeline_subscriber( + client: NatsAdminClient, + pipeline: Pipeline, + subject: String, + ctx: Arc, +) { + let mut sub = match client.inner().subscribe(subject.clone()).await { + Ok(s) => s, + Err(e) => { + error!( + pipeline = %pipeline.name, + subject = %subject, + error = %e, + "pipelines: failed to subscribe to NATS subject" + ); + return; + } + }; + + info!( + pipeline = %pipeline.name, + subject = %subject, + "pipelines: core NATS subscriber ready (no durability)" + ); + + while let Some(msg) = sub.next().await { + execute_pipeline(&pipeline, msg.subject.as_str(), &msg.payload, &ctx).await; + } + + warn!( + pipeline = %pipeline.name, + subject = %subject, + "pipelines: core NATS subscriber stream ended" + ); +} + +/// Check if a received subject matches a pipeline subject pattern. +/// +/// Supports NATS wildcard `>` suffix: `"evol.website.dev.>"` matches any +/// subject starting with `"evol.website.dev."`. Exact matches always pass. +fn subject_matches(pattern: &str, subject: &str) -> bool { + if pattern == subject { + return true; + } + if let Some(prefix) = pattern.strip_suffix('>') { + return subject.starts_with(prefix); + } + // Single-token wildcard `*` — matches one token + let pat_tokens: Vec<&str> = pattern.split('.').collect(); + let sub_tokens: Vec<&str> = subject.split('.').collect(); + if pat_tokens.len() != sub_tokens.len() { + return false; + } + pat_tokens + .iter() + .zip(sub_tokens.iter()) + .all(|(p, s)| *p == "*" || p == s) +} diff --git a/crates/foundation/crates/rustelo_server/src/startup/router.rs b/crates/foundation/crates/rustelo_server/src/startup/router.rs index 239e485..2bcfb66 100644 --- a/crates/foundation/crates/rustelo_server/src/startup/router.rs +++ b/crates/foundation/crates/rustelo_server/src/startup/router.rs @@ -7,20 +7,38 @@ use std::collections::HashSet; use std::path::PathBuf; use axum::{extract::State, http::HeaderMap, response::IntoResponse, routing::get, Router}; -use leptos::prelude::*; use rustelo_core_lib::routing::get_routing_config; use tower_http::services::ServeDir; use super::handlers::fallback_handler; use crate::app_state::AppState; -use crate::config::{Config, Environment}; -use crate::run::render_ssr_with_context; +use crate::config::Config; +use crate::run::render_route_response; +use crate::run::SiteOptions; use crate::utils::lang_detection; +/// Clone the request headers and inject the raw query string as a synthetic +/// `x-request-query` header. Shell renderers (e.g. the HTMX content grid) read +/// it via `current_request_headers()` so direct URLs like `/blog?tag=rust` +/// can pre-populate filter/search state. Standard HTTP requests carry the query +/// only in the URI, which the renderer never sees otherwise. +fn inject_query_header(headers: &HeaderMap, uri: &axum::http::Uri) -> HeaderMap { + let mut augmented = headers.clone(); + if let Some(query) = uri.query() { + if let Ok(value) = axum::http::HeaderValue::from_str(query) { + augmented.insert( + axum::http::HeaderName::from_static("x-request-query"), + value, + ); + } + } + augmented +} + /// Create the main application router with all routes and static file serving pub fn create_main_router( config: &Config, - leptos_options: LeptosOptions, + site_options: SiteOptions, app_state: AppState, ) -> Result> { // Create a custom route handler for the root path instead of automatic Leptos @@ -29,7 +47,7 @@ pub fn create_main_router( .route( "/", get( - move |State(opts): State, headers: HeaderMap| async move { + move |State(opts): State, headers: HeaderMap| async move { // Detect language preference from cookies/headers let detected_lang = lang_detection::detect_ssr_language("/", &headers); @@ -46,9 +64,10 @@ pub fn create_main_router( } } - // Otherwise render the page normally - axum::response::Html(render_ssr_with_context(opts, Some("/".to_string()))) - .into_response() + // Dispatch through render_route_response so HX-Request / HX-Boosted + // selects between full shell and
    fragment per profile. + let resolution = rustelo_core_lib::routing::engine::resolve_route("/"); + render_route_response(opts, &resolution, &headers) }, ), ) @@ -74,22 +93,76 @@ pub fn create_main_router( // Add specific route for kitdigital.html to serve from public directory .route( "/kitdigital.html", + get({ + let kitdigital_path = PathBuf::from(&config.server_dirs.public_dir) + .join("kitdigital.html") + .to_string_lossy() + .into_owned(); + move || async move { + match tokio::fs::read_to_string(&kitdigital_path).await { + Ok(content) => axum::response::Html(content).into_response(), + Err(_) => axum::response::Response::builder() + .status(404) + .body(axum::body::Body::from("404 - File not found")) + .unwrap() + .into_response(), + } + } + }), + ) + // Add dynamic routes from SSR routing configuration + .merge(create_dynamic_routes(site_options.clone())); + // WASM package directory — only exists in leptos-hydration builds. + #[cfg(feature = "leptos-hydration")] + let app = app.nest_service( + "/pkg", + ServeDir::new(format!("{}/pkg", site_options.site_root)) + .append_index_html_on_directories(false), + ); + // htmx runtime dir: HTMX_ASSETS_PATH override (set by the local-install + // wrapper / container) falls back to /templates/shared/htmx. + // Mirrors HTMX_TEMPLATE_PATH for the minijinja templates — lets the runtime + // assets live outside the site tree (e.g. ~/.local/share or /usr/local/share). + let htmx_assets_dir = std::env::var("HTMX_ASSETS_PATH") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| crate::utils::resolve_from_root("templates/shared/htmx")); + let app = app + // Vendored htmx runtime + extensions for the htmx-ssr rendering profile. + // Mounted unconditionally: harmless when the workspace is leptos-hydration + // (the shell never emits + + + + } +} diff --git a/registry/Cargo.toml b/registry/Cargo.toml new file mode 100644 index 0000000..c6a10bc --- /dev/null +++ b/registry/Cargo.toml @@ -0,0 +1,223 @@ +# Rustelo — Workspace Dependencies Registry +# +# Single source of truth for [workspace.dependencies] across rustelo and all +# implementations. This file is a Cargo workspace manifest with zero members — +# its only purpose is to host the canonical dependency table where Cargo-native +# tooling (Zed/rust-analyzer, cargo-edit, Dependabot, cargo-outdated) can read +# and update versions naturally. +# +# Sync workflow: +# cargo xtask sync-deps --check verify rustelo/Cargo.toml matches +# cargo xtask sync-deps rewrite rustelo/Cargo.toml +# cargo xtask sync-deps --target rewrite /Cargo.toml (paths rebased) +# +# Path metadata that does not fit native Cargo (e.g. "do not rebase this path +# when propagating to other workspaces") lives in `sync.toml` alongside. + +[workspace] +resolver = "2" +members = [] + +[workspace.dependencies] +# --- Rustelo foundation crates --------------------------------------------- +rustelo_utils = { path = "../crates/foundation/crates/rustelo_utils" } +rustelo_core_types = { path = "../crates/foundation/crates/rustelo_core_types" } +rustelo_language = { path = "../crates/foundation/crates/rustelo_language" } +rustelo_routing = { path = "../crates/foundation/crates/rustelo_routing" } +rustelo_core_lib = { path = "../crates/foundation/crates/rustelo_core_lib" } +rustelo_components_leptos = { path = "../crates/foundation/crates/rustelo_components_leptos" } +rustelo_components_htmx = { path = "../crates/foundation/crates/rustelo_components_htmx" } +rustelo_pages_leptos = { path = "../crates/foundation/crates/rustelo_pages_leptos" } +rustelo_pages_htmx = { path = "../crates/foundation/crates/rustelo_pages_htmx" } +rustelo_seo = { path = "../crates/foundation/crates/rustelo_seo" } +rustelo_client = { path = "../crates/foundation/crates/rustelo_client" } +rustelo_server = { path = "../crates/foundation/crates/rustelo_server", default-features = false } +rustelo_tools = { path = "../crates/foundation/crates/rustelo_tools" } +rustelo_macros = { path = "../crates/foundation/crates/rustelo_macros" } +rustelo_config = { path = "../crates/foundation/crates/rustelo_config" } + +# --- Rustelo framework crates ---------------------------------------------- +rustelo_core = { path = "../crates/framework/crates/rustelo_core" } +rustelo_web = { path = "../crates/framework/crates/rustelo_web" } +rustelo_auth = { path = "../crates/framework/crates/rustelo_auth" } +rustelo_content = { path = "../crates/framework/crates/rustelo_content" } +rustelo_cli = { path = "../crates/framework/crates/rustelo_cli" } + +# --- External path deps (outside rustelo, no-rebase via sync.toml) --------- +platform-nats = { path = "../../../stratumiops/code/crates/platform-nats" } + +# --- Leptos ecosystem ------------------------------------------------------ +leptos = "=0.8.15" +leptos_router = "=0.8.11" +leptos_axum = "=0.8.7" +any_spawner = "0.3" +leptos_config = "=0.8.8" +leptos_meta = "=0.8.5" +leptos_integration_utils = "=0.8.7" +reactive_graph = "0.2.14" + +# --- Serialization / common ------------------------------------------------ +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +serde-wasm-bindgen = "0.6.5" +toml = "1.1.2" + +# --- HTTP / runtime -------------------------------------------------------- +axum = "0.8.9" +axum-server = { version = "0.8", features = ["tls-rustls"] } +axum-test = "20.0" +tokio = { version = "1.52", features = ["rt-multi-thread"] } +tokio-util = { version = "0.7", features = ["io"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tokio-test = "0.4" +tower = "0.5.3" +tower-http = { version = "0.6.11", features = ["fs"] } +tower-sessions = "0.15" +tower-cookies = "0.11" +http = "1" +reqwest = { version = "0.13.4", features = ["json"] } +reqwasm = "0.5.0" + +# --- WASM / browser -------------------------------------------------------- +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "=0.3.97" +web-sys = { version = "=0.3.97", features = [ + "Clipboard", + "Window", + "Navigator", + "Permissions", + "MouseEvent", + "KeyboardEvent", + "Event", + "Storage", + "console", + "File", + "SvgElement", + "SvgsvgElement", + "SvgPathElement", + "MediaQueryList", +] } +gloo-net = "0.7.0" +gloo-timers = { version = "0.4", features = ["futures"] } +console_error_panic_hook = "0.1.7" +console_log = "1" + +# --- Errors / logging ------------------------------------------------------ +anyhow = "1.0.102" +thiserror = "2.0.18" +log = "0.4.30" +env_logger = "0.11" +tracing = "0.1" +tracing-subscriber = "0.3" + +# --- i18n ------------------------------------------------------------------ +fluent = "0.17" +fluent-bundle = "0.16" +fluent-syntax = "0.12" +fluent-templates = { version = "0.14.0", features = ["tera"] } +unic-langid = { version = "0.9", features = ["unic-langid-macros"] } + +# --- Auth / crypto --------------------------------------------------------- +jsonwebtoken = { version = "10.4", features = ["rust_crypto"] } +argon2 = "0.5" +oauth2 = "5.0" +rustls = "0.23" +rustls-pemfile = "2.2" +totp-rs = "5.7" +qrcode = { version = "0.14", features = ["svg"] } +base32 = "0.5" +sha2 = "0.11" +base64 = "0.22" +aes-gcm = "0.10" +hex = "0.4.3" + +# --- Storage / data -------------------------------------------------------- +sqlx = { version = "0.9.0", features = [ + "runtime-tokio", + "tls-rustls-ring", + "postgres", + "sqlite", + "chrono", + "uuid", + "migrate", +] } +uuid = { version = "1.23", features = ["v4", "serde", "js"] } +chrono = { version = "0.4", features = ["serde"] } +time = { version = "0.3", features = ["serde"] } + +# --- Content / templating -------------------------------------------------- +pulldown-cmark = { version = "0.13", features = ["simd"] } +comrak = { version = "0.52", features = ["syntect"] } +tera = "1.20" +minijinja = { version = "2", features = ["loader", "builtins", "json"] } +handlebars = "6.4" +gray_matter = "0.3" +syntect = "5.3" +ammonia = "4.1" +scraper = "0.27" +html-escape = "0.2" +unicode-normalization = "0.1" + +# --- Email ----------------------------------------------------------------- +lettre = { version = "0.11", default-features = false, features = [ + "tokio1-rustls-tls", + "smtp-transport", + "pool", + "hostname", + "builder", +] } +urlencoding = "2.1" + +# --- Misc utility ---------------------------------------------------------- +regex = "1.12.3" +glob = "0.3.3" +walkdir = "2.5" +ignore = "0.4" +tempfile = "3.27" +notify = { version = "8.2.0", default-features = false } +dotenv = "0.15.0" +async-trait = "0.1.89" +async-compression = { version = "0.4", features = ["gzip", "tokio"] } +async-nats = "0.49" +nkeys = "0.4" +bytes = "1.11" +futures = "0.3.32" +rand = "0.10" +rand_core = "0.10" +getrandom = { version = "0.4", features = ["std", "wasm_js"] } +once_cell = "1.21.4" +dashmap = "6" +lru = "0.18" +cfg-if = "1.0" +paste = "1.0.15" +typed-builder = "0.23" +rhai = { version = "1.25", features = ["serde", "only_i64", "no_float"] } + +# --- CLI / TUI ------------------------------------------------------------- +clap = { version = "4.6", features = ["derive", "env"] } +ratatui = "0.30" +inquire = "0.9" +crossterm = "0.29" +dialoguer = "0.12" +console = "0.16" +indicatif = "0.18" +similar = "3.1" + +# --- Macro support --------------------------------------------------------- +syn = { version = "2.0", features = ["full"] } +quote = "1.0" +proc-macro2 = "1.0" + +# --- Metrics --------------------------------------------------------------- +prometheus = "0.14" + +# --- Path / version utilities ---------------------------------------------- +shellexpand = "3.1" +semver = "1.0" +pathdiff = "0.2" + +# --- Test mocking ---------------------------------------------------------- +mockall = "0.14" +wiremock = "0.6" diff --git a/registry/sync.toml b/registry/sync.toml new file mode 100644 index 0000000..9027de6 --- /dev/null +++ b/registry/sync.toml @@ -0,0 +1,21 @@ +# Non-Cargo metadata for `cargo xtask sync-deps`. +# +# Path deps in registry/Cargo.toml that should NOT be rebased onto each target +# workspace. Default behavior: every path is rebased — emitted relative to the +# target's Cargo.toml location. Entries listed here are emitted relative to the +# rustelo workspace root instead, so the same string ships to all targets. +# +# Use this only for paths that point outside the rustelo workspace (e.g. sibling +# projects whose relative location is identical from each impl) and where you +# want every Cargo.toml to declare the same literal path. + +no_rebase = ["platform-nats"] + +# Implementations that consume this registry. Paths are relative to the +# rustelo workspace root. `cargo xtask sync-deps --all` syncs rustelo plus +# every entry here, in declaration order. Each path must contain a Cargo.toml; +# the xtask validates that at load time. +[impls] +known = [ + "../jpl-website", +]