Compare commits
13 Commits
recovery/d
...
f94c75cdd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94c75cdd0 | ||
|
|
7b9d3998b1 | ||
|
|
c62c9991d6 | ||
|
|
8f239c6ff4 | ||
|
|
399c203b10 | ||
|
|
eba624d58b | ||
|
|
582348ef45 | ||
|
|
f6e1a2e616 | ||
|
|
66dfedaf20 | ||
|
|
f776f61430 | ||
|
|
c9d3afe4c0 | ||
| 9393886836 | |||
| 5cbadb17e1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ CLAUDE.md
|
|||||||
.atl/
|
.atl/
|
||||||
.npm
|
.npm
|
||||||
__pycache__
|
__pycache__
|
||||||
|
build_test/
|
||||||
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -1925,6 +1925,7 @@ dependencies = [
|
|||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tray-icon",
|
"tray-icon",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -6900,6 +6901,18 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-appender"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-channel",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.31"
|
version = "0.1.31"
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ services:
|
|||||||
- MQTT_USER=${MQTT_USER}
|
- MQTT_USER=${MQTT_USER}
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
- MQTT_PASSWORD=${MQTT_PASSWORD}
|
||||||
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
|
- MINIO_ENDPOINT_URL=http://minio:9000
|
||||||
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -130,6 +133,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
|
||||||
- MINIO_ENDPOINT=http://minio:9000
|
- MINIO_ENDPOINT=http://minio:9000
|
||||||
|
- AWS_ACCESS_KEY_ID=${MINIO_USER}
|
||||||
|
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
|
||||||
depends_on:
|
depends_on:
|
||||||
backend-api:
|
backend-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
52
docs/REPORTE_CORRECCION_AUTH_MOBILE.md
Normal file
52
docs/REPORTE_CORRECCION_AUTH_MOBILE.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Reporte de correccion auth mobile y recuperacion asociada
|
||||||
|
|
||||||
|
## 1. Contexto del problema
|
||||||
|
Durante la recuperacion del proyecto se detecto que la superficie mobile (PWA de campo) estaba mezclando responsabilidades de autenticacion y navegacion con la superficie administrativa. Esto generaba inconsistencias en el acceso, confusion para usuarios de campo y una exposicion indebida del flujo de registro publico desde mobile.
|
||||||
|
|
||||||
|
## 2. Que estaba ocurriendo
|
||||||
|
- La PWA mobile mostraba y exponia un flujo de registro publico (`/register`) que no correspondia al modelo operativo real.
|
||||||
|
- El backend todavia aceptaba el endpoint de registro publico, en lugar de bloquearlo de forma explicita.
|
||||||
|
- La experiencia de login en mobile todavia arrastraba señales visuales y de navegacion propias del entorno admin.
|
||||||
|
- La separacion entre `app.omnioil.app` (admin) y `mobile.omnioil.app` (campo) no estaba completamente resuelta, incluyendo redirecciones y recuperacion de sesion.
|
||||||
|
|
||||||
|
## 3. Que se diagnostico
|
||||||
|
- El registro publico no debia existir en mobile: el alta de usuarios de campo debe quedar bajo provisionamiento administrado.
|
||||||
|
- Habia que reforzar la separacion de superficies entre admin y mobile tanto a nivel UI como a nivel routing/autorizacion.
|
||||||
|
- La recuperacion de sesion necesitaba cubrir mejor el arranque de la PWA para evitar estados en blanco o redirecciones inconsistentes.
|
||||||
|
- El bloqueo del registro debia hacerse en dos capas: quitando la entrada desde frontend y devolviendo una respuesta formal desde backend.
|
||||||
|
|
||||||
|
## 4. Que se corrigio
|
||||||
|
- Se deshabilito el registro publico en mobile.
|
||||||
|
- Se elimino la ruta `/register` de la PWA y se retiro la pantalla/formulario asociado.
|
||||||
|
- El login mobile dejo de invitar al auto-registro y ahora indica que el alta debe solicitarse a un administrador.
|
||||||
|
- El backend ahora responde `403 Forbidden` cuando se intenta usar el registro publico, con un mensaje explicito indicando que el alta publica esta deshabilitada.
|
||||||
|
- Se consolido la separacion de superficies entre admin y mobile:
|
||||||
|
- mobile queda orientado a operadores/cuadrillas y tareas de campo;
|
||||||
|
- admin queda como superficie administrativa independiente.
|
||||||
|
- Se fortalecio la resolucion de rutas segun rol y dominio para redirigir al entorno correcto.
|
||||||
|
- Se mejoro la recuperacion de sesion/bootstrapping para que la app no quede en blanco mientras valida el acceso.
|
||||||
|
|
||||||
|
## 5. Validaciones realizadas
|
||||||
|
- Validacion local de los flujos de autenticacion y navegacion por superficie.
|
||||||
|
- Validacion en runtime de:
|
||||||
|
- redireccion por dominio/rol;
|
||||||
|
- recuperacion de sesion al iniciar la PWA;
|
||||||
|
- manejo de sesiones expiradas;
|
||||||
|
- bloqueo del registro publico.
|
||||||
|
- Cobertura automatizada agregada/ajustada para:
|
||||||
|
- respuesta `403` del backend al registro deshabilitado;
|
||||||
|
- identidad visual y mensajes exclusivos de mobile;
|
||||||
|
- redirecciones entre superficie admin y superficie mobile;
|
||||||
|
- bootstrap de sesion y manejo de refresh.
|
||||||
|
|
||||||
|
## 6. Estado final
|
||||||
|
- El registro publico en mobile quedo deshabilitado.
|
||||||
|
- La PWA mobile ya no expone una via de alta publica.
|
||||||
|
- El backend protege el endpoint de registro devolviendo `403`.
|
||||||
|
- La separacion entre superficie administrativa y superficie mobile quedo implementada de forma mas consistente.
|
||||||
|
- El flujo de login/recuperacion de sesion en mobile quedo estabilizado dentro del trabajo de recuperacion del proyecto.
|
||||||
|
|
||||||
|
## 7. Notas y riesgos pendientes
|
||||||
|
- Si existieran clientes antiguos cacheados apuntando a `/register`, recibiran el nuevo comportamiento bloqueado y deberian actualizarse.
|
||||||
|
- El alta de usuarios depende ahora completamente del flujo administrado, por lo que conviene mantener documentado ese procedimiento operativo.
|
||||||
|
- Como siguiente control recomendable, conviene revisar periodicamente que no reaparezcan accesos cruzados entre superficies en futuros cambios de auth o routing.
|
||||||
@@ -56,7 +56,9 @@ server {
|
|||||||
server_name api.omnioil.app;
|
server_name api.omnioil.app;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://backend-api:8000;
|
resolver 127.0.0.11 valid=30s;
|
||||||
|
set $backend_upstream backend-api;
|
||||||
|
proxy_pass http://$backend_upstream:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- MIGRATION: Add legal info to projects level only
|
||||||
|
-- Date: 2026-04-11
|
||||||
|
|
||||||
|
-- 1. Asegurar que edge_projects tenga los campos obligatorios
|
||||||
|
ALTER TABLE edge_projects
|
||||||
|
ADD COLUMN IF NOT EXISTS operator_name VARCHAR(150),
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_number VARCHAR(100);
|
||||||
|
|
||||||
|
-- 2. Para datos de prueba existentes, llenamos con valores base
|
||||||
|
-- (Solo por integridad de la migración, el usuario dice que no importa)
|
||||||
|
UPDATE edge_projects
|
||||||
|
SET operator_name = client
|
||||||
|
WHERE operator_name IS NULL;
|
||||||
|
|
||||||
|
UPDATE edge_projects
|
||||||
|
SET contract_number = 'PENDIENTE'
|
||||||
|
WHERE contract_number IS NULL;
|
||||||
|
|
||||||
|
-- 3. Hacerlos NOT NULL para el futuro
|
||||||
|
ALTER TABLE edge_projects
|
||||||
|
ALTER COLUMN operator_name SET NOT NULL,
|
||||||
|
ALTER COLUMN contract_number SET NOT NULL;
|
||||||
@@ -14,9 +14,8 @@ use std::env;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AuthError {
|
pub enum AuthError {
|
||||||
InvalidCredentials,
|
InvalidCredentials,
|
||||||
EmailAlreadyExists,
|
|
||||||
AccountDisabled,
|
AccountDisabled,
|
||||||
InvalidInput(String),
|
RegistrationDisabled,
|
||||||
Internal(anyhow::Error),
|
Internal(anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,12 +26,15 @@ impl IntoResponse for AuthError {
|
|||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
"Credenciales inválidas".to_string(),
|
"Credenciales inválidas".to_string(),
|
||||||
),
|
),
|
||||||
AuthError::EmailAlreadyExists => (StatusCode::CONFLICT, "El correo ya se encuentra registrado".to_string()),
|
|
||||||
AuthError::AccountDisabled => (
|
AuthError::AccountDisabled => (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"Tu cuenta ha sido desactivada. Contacta al administrador.".to_string(),
|
"Tu cuenta ha sido desactivada. Contacta al administrador.".to_string(),
|
||||||
),
|
),
|
||||||
AuthError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg),
|
AuthError::RegistrationDisabled => (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"El alta publica esta deshabilitada. Solicita acceso a un administrador."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
AuthError::Internal(ref e) => {
|
AuthError::Internal(ref e) => {
|
||||||
tracing::error!("Auth internal error: {:?}", e);
|
tracing::error!("Auth internal error: {:?}", e);
|
||||||
(
|
(
|
||||||
@@ -52,6 +54,32 @@ impl IntoResponse for AuthError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::AuthError;
|
||||||
|
use axum::{body, http::StatusCode, response::IntoResponse};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registration_disabled_maps_to_forbidden_response() {
|
||||||
|
let response = AuthError::RegistrationDisabled.into_response();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let body = body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("response body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json response");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
payload,
|
||||||
|
json!({
|
||||||
|
"error": "El alta publica esta deshabilitada. Solicita acceso a un administrador."
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: String, // User ID
|
pub sub: String, // User ID
|
||||||
@@ -119,8 +147,6 @@ pub fn create_jwt_tokens(uid: &str, role: &str, projects: Vec<uuid::Uuid>) -> Re
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub fn verify_jwt(token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> {
|
pub fn verify_jwt(token: &str) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> {
|
||||||
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
decode(
|
decode(
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ use crate::{
|
|||||||
auth::{AuthError, create_jwt_tokens},
|
auth::{AuthError, create_jwt_tokens},
|
||||||
db::DbPool,
|
db::DbPool,
|
||||||
};
|
};
|
||||||
use argon2::{
|
use argon2::{Argon2, password_hash::{PasswordHash, PasswordVerifier}};
|
||||||
Argon2,
|
|
||||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core},
|
|
||||||
};
|
|
||||||
use axum::{Json, extract::State, response::IntoResponse};
|
use axum::{Json, extract::State, response::IntoResponse};
|
||||||
use rand_core::OsRng;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -17,13 +13,6 @@ pub struct LoginPayload {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct RegisterPayload {
|
|
||||||
email: String,
|
|
||||||
password: String,
|
|
||||||
full_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct UserRow {
|
struct UserRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
@@ -208,78 +197,12 @@ pub async fn refresh(
|
|||||||
Err(AuthError::InvalidCredentials)
|
Err(AuthError::InvalidCredentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct RegisterResult {
|
|
||||||
id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(pool): State<DbPool>,
|
State(pool): State<DbPool>,
|
||||||
Json(payload): Json<RegisterPayload>,
|
Json(_payload): Json<serde_json::Value>,
|
||||||
) -> Result<impl IntoResponse, AuthError> {
|
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
// 1. Validaciones de entrada
|
let _ = pool;
|
||||||
if payload.email.is_empty() || !payload.email.contains('@') {
|
|
||||||
return Err(AuthError::InvalidInput(
|
|
||||||
"Formato de correo electrónico inválido".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.password.len() < 8 {
|
// Public signup stays disabled so access provisioning only happens from managed admin flows.
|
||||||
return Err(AuthError::InvalidInput(
|
return Err(AuthError::RegistrationDisabled);
|
||||||
"La contraseña debe tener al menos 8 caracteres".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.full_name.trim().is_empty() {
|
|
||||||
return Err(AuthError::InvalidInput(
|
|
||||||
"El nombre completo es obligatorio".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Cifrar la contraseña
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let argon2 = Argon2::default();
|
|
||||||
let password_hash = argon2
|
|
||||||
.hash_password(payload.password.as_bytes(), &salt)
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!("Hashing error: {}", e);
|
|
||||||
AuthError::Internal(anyhow::anyhow!("Error de cifrado"))
|
|
||||||
})?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// 2. Insert the user
|
|
||||||
// We default to role 'admin' (id 3 as per init.sql) if not specified
|
|
||||||
//El operador es solo para la pwa.
|
|
||||||
let register_result: RegisterResult = sqlx::query_as::<sqlx::Postgres, RegisterResult>(
|
|
||||||
r#"
|
|
||||||
INSERT INTO users (email, password_hash, full_name, role_id)
|
|
||||||
VALUES ($1, $2, $3, (SELECT id FROM roles WHERE name = 'admin'))
|
|
||||||
RETURNING id
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&payload.email)
|
|
||||||
.bind(&password_hash)
|
|
||||||
.bind(&payload.full_name)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!("User creation error: {}", e);
|
|
||||||
let err_str = e.to_string().to_lowercase();
|
|
||||||
if err_str.contains("unique constraint") || err_str.contains("already exists") {
|
|
||||||
AuthError::EmailAlreadyExists
|
|
||||||
} else {
|
|
||||||
AuthError::Internal(anyhow::anyhow!("Error interno al crear usuario"))
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"User created: {} with ID: {}",
|
|
||||||
payload.email,
|
|
||||||
register_result.id
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
axum::http::StatusCode::CREATED,
|
|
||||||
Json(json!({ "id": register_result.id, "email": payload.email })),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ pub async fn create_project(
|
|||||||
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let project = sqlx::query_as::<_, EdgeProject>(
|
let project = sqlx::query_as::<_, EdgeProject>(
|
||||||
r#"INSERT INTO edge_projects (name, client, description, metadata)
|
r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *"#,
|
RETURNING *"#,
|
||||||
)
|
)
|
||||||
.bind(&req.name)
|
.bind(&req.name)
|
||||||
.bind(&req.client)
|
.bind(&req.client)
|
||||||
|
.bind(&req.operator_name)
|
||||||
|
.bind(&req.contract_number)
|
||||||
.bind(&req.description)
|
.bind(&req.description)
|
||||||
.bind(req.metadata.unwrap_or(serde_json::json!({})))
|
.bind(req.metadata.unwrap_or(serde_json::json!({})))
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
@@ -327,10 +329,10 @@ pub async fn download_installer(
|
|||||||
// Configurar credenciales explícitas
|
// Configurar credenciales explícitas
|
||||||
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
let access_key = std::env::var("AWS_ACCESS_KEY_ID")
|
||||||
.or_else(|_| std::env::var("MINIO_USER"))
|
.or_else(|_| std::env::var("MINIO_USER"))
|
||||||
.unwrap_or_else(|_| "admin_s3".to_string());
|
.expect("S3/MinIO credentials (AWS_ACCESS_KEY_ID or MINIO_USER) must be set");
|
||||||
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
|
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
|
||||||
.or_else(|_| std::env::var("MINIO_PASSWORD"))
|
.or_else(|_| std::env::var("MINIO_PASSWORD"))
|
||||||
.unwrap_or_else(|_| "otra_clave_segura_minio".to_string());
|
.expect("S3/MinIO credentials (AWS_SECRET_ACCESS_KEY or MINIO_PASSWORD) must be set");
|
||||||
|
|
||||||
let credentials = aws_sdk_s3::config::Credentials::new(
|
let credentials = aws_sdk_s3::config::Credentials::new(
|
||||||
access_key,
|
access_key,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
|||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2.3"
|
||||||
axum = { version = "0.8.8", features = ["macros"] }
|
axum = { version = "0.8.8", features = ["macros"] }
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
tower-http = { version = "0.6.8", features = ["trace", "cors"] }
|
tower-http = { version = "0.6.8", features = ["trace", "cors"] }
|
||||||
|
|||||||
@@ -62,18 +62,6 @@ while true; do
|
|||||||
# Solo nos aseguramos de que el icono esté accesible para wixl
|
# Solo nos aseguramos de que el icono esté accesible para wixl
|
||||||
cp -f icon.ico target/x86_64-pc-windows-msvc/release/icon.ico || true
|
cp -f icon.ico target/x86_64-pc-windows-msvc/release/icon.ico || true
|
||||||
|
|
||||||
# Crear un agent.toml base si no existe (clímax para evitar fallo de servicio)
|
|
||||||
if [ ! -f "agent.toml" ]; then
|
|
||||||
cat <<EOF > agent.toml
|
|
||||||
[agent]
|
|
||||||
project_id = "00000000-0000-0000-0000-000000000000"
|
|
||||||
[mqtt]
|
|
||||||
host = "localhost"
|
|
||||||
[docker]
|
|
||||||
[server]
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Ejecutar WiXl para generar el MSI
|
# 2. Ejecutar WiXl para generar el MSI
|
||||||
wixl -v -o "$OUTPUT_DIR/$MSI_NAME" "wix/main.wxs"
|
wixl -v -o "$OUTPUT_DIR/$MSI_NAME" "wix/main.wxs"
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use log_reporter::LogReporter;
|
|||||||
use mqtt_listener::AgentCommand;
|
use mqtt_listener::AgentCommand;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{RwLock, mpsc};
|
use tokio::sync::{RwLock, mpsc};
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "agent.toml";
|
const DEFAULT_CONFIG_PATH: &str = "agent.toml";
|
||||||
@@ -32,15 +32,42 @@ const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Inicializar tracing
|
// Inicializar tracing con salida a consola y a archivo para errores
|
||||||
|
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||||
|
let log_dir = exe_path.parent().unwrap_or(std::path::Path::new(".")).join("logs");
|
||||||
|
|
||||||
|
let file_appender = tracing_appender::rolling::daily(log_dir, "edge-agent.log");
|
||||||
|
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
|
||||||
|
let console_layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_writer(std::io::stdout);
|
||||||
|
|
||||||
|
let file_layer = tracing_subscriber::fmt::layer()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(non_blocking);
|
||||||
|
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
.unwrap_or_else(|_| "info,edge_agent=debug".into()),
|
.unwrap_or_else(|_| "info,edge_agent=debug".into()),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(console_layer)
|
||||||
|
.with(file_layer.with_filter(tracing_subscriber::filter::LevelFilter::WARN))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
// Capturar Panics en el log
|
||||||
|
std::panic::set_hook(Box::new(|panic_info| {
|
||||||
|
let location = panic_info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let payload = panic_info.payload().downcast_ref::<&str>().cloned()
|
||||||
|
.or_else(|| panic_info.payload().downcast_ref::<String>().map(|s| s.as_str()))
|
||||||
|
.unwrap_or("no message");
|
||||||
|
tracing::error!("CRITICAL PANIC at {}: {}", location, payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Guardar el _guard para que los logs sigan fluyendo
|
||||||
|
// Como _guard se destruye al final de main, está bien.
|
||||||
|
// Pero main.rs es async, así que lo mantenemos en el scope de main.
|
||||||
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
// Command: --generate-config
|
// Command: --generate-config
|
||||||
@@ -85,7 +112,11 @@ async fn main() -> Result<()> {
|
|||||||
let config = AgentConfig::load_default(std::path::Path::new(&config_path))
|
let config = AgentConfig::load_default(std::path::Path::new(&config_path))
|
||||||
.with_context(|| "Failed to load agent configuration (baked or external)")?;
|
.with_context(|| "Failed to load agent configuration (baked or external)")?;
|
||||||
|
|
||||||
run_agent(config).await
|
if let Err(e) = run_agent(config).await {
|
||||||
|
tracing::error!("Fatal error: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_agent(config: AgentConfig) -> Result<()> {
|
pub async fn run_agent(config: AgentConfig) -> Result<()> {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ impl StoreAndForward {
|
|||||||
|
|
||||||
// 2. Preparar Cifrado
|
// 2. Preparar Cifrado
|
||||||
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
|
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
|
||||||
.unwrap_or_else(|_| "dev_master_secret_omnioil_2026".to_string());
|
.expect("OMNIOIL_MASTER_SECRET must be set for Edge Agent database encryption");
|
||||||
|
|
||||||
let mut key = [0u8; 32];
|
let mut key = [0u8; 32];
|
||||||
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key);
|
pbkdf2_hmac::<Sha256>(master_secret.as_bytes(), SALT, 1000, &mut key);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pub fn run_service() -> Result<()> {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn service_main(_args: Vec<OsString>) {
|
fn service_main(_args: Vec<OsString>) {
|
||||||
if let Err(e) = service_handler() {
|
if let Err(e) = service_handler() {
|
||||||
eprintln!("Service error: {}", e);
|
tracing::error!("Service critical error: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,19 +68,24 @@ fn service_handler() -> Result<()> {
|
|||||||
let exe_path = std::env::current_exe().unwrap();
|
let exe_path = std::env::current_exe().unwrap();
|
||||||
let config_path = exe_path.with_file_name("agent.toml");
|
let config_path = exe_path.with_file_name("agent.toml");
|
||||||
|
|
||||||
if let Ok(config) = crate::config::AgentConfig::load_default(&config_path) {
|
match crate::config::AgentConfig::load_default(&config_path) {
|
||||||
tokio::select! {
|
Ok(config) => {
|
||||||
res = crate::run_agent(config) => {
|
tokio::select! {
|
||||||
if let Err(e) = res {
|
res = crate::run_agent(config) => {
|
||||||
tracing::error!("Agent exited with error: {}", e);
|
if let Err(e) = res {
|
||||||
|
tracing::error!("Agent exited with error: {}", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
tracing::info!("Service stopping via SCM signal");
|
||||||
}
|
}
|
||||||
},
|
|
||||||
_ = shutdown_rx.recv() => {
|
|
||||||
tracing::info!("Service stopping via SCM signal");
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to load configuration at {:?}: {}", config_path, e);
|
||||||
|
// Esperar un poco antes de salir para que el log se escriba
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
shutdown_rx.recv().await;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
|
|
||||||
<Component Id='MainExecutable' Guid='B278D874-45B5-4CBA-A840-3F02AB62F9FD'>
|
<Component Id='MainExecutable' Guid='B278D874-45B5-4CBA-A840-3F02AB62F9FD'>
|
||||||
<File Id='EdgeAgentEXE' Source='target/x86_64-pc-windows-msvc/release/edge-agent.exe' KeyPath='yes' />
|
<File Id='EdgeAgentEXE' Source='target/x86_64-pc-windows-msvc/release/edge-agent.exe' KeyPath='yes' />
|
||||||
<File Id='AgentConfigTOML' Source='agent.toml' />
|
|
||||||
|
|
||||||
<!-- Servicio de Windows -->
|
<!-- Servicio de Windows -->
|
||||||
<ServiceInstall Id="ServiceInstaller"
|
<ServiceInstall Id="ServiceInstaller"
|
||||||
|
|||||||
@@ -30,27 +30,39 @@ RUN pnpm build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Configuración Nginx optimizada para SPA y Proxy Inverso
|
# Configuración Nginx optimizada para SPA y Cache Control
|
||||||
RUN echo 'server { \
|
RUN echo 'server { \
|
||||||
listen 80; \
|
listen 80; \
|
||||||
\
|
|
||||||
# Ruta principal para la SPA (React/Vue) \
|
|
||||||
location / { \
|
|
||||||
root /usr/share/nginx/html; \
|
root /usr/share/nginx/html; \
|
||||||
index index.html index.htm; \
|
index index.html; \
|
||||||
try_files $uri $uri/ /index.html; \
|
\
|
||||||
|
# Cache Control para el index.html (NUNCA cachear) \
|
||||||
|
location = /index.html { \
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; \
|
||||||
} \
|
} \
|
||||||
\
|
\
|
||||||
# Proxy para evitar errores de conexión y CORS \
|
# Cache Control para assets estáticos (Vite usa hashes, así que es seguro cachear) \
|
||||||
location /api/ { \
|
location /assets/ { \
|
||||||
proxy_pass http://backend-api:8000; \
|
expires 1y; \
|
||||||
proxy_http_version 1.1; \
|
add_header Cache-Control "public, immutable"; \
|
||||||
proxy_set_header Upgrade $http_upgrade; \
|
|
||||||
proxy_set_header Connection "upgrade"; \
|
|
||||||
proxy_set_header Host $host; \
|
|
||||||
proxy_cache_bypass $http_upgrade; \
|
|
||||||
} \
|
} \
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
\
|
||||||
|
# Ruta principal para la SPA \
|
||||||
|
location / { \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
\
|
||||||
|
# Proxy para la API con re-resolución DNS automática \
|
||||||
|
location /api/ { \
|
||||||
|
resolver 127.0.0.11 valid=30s; \
|
||||||
|
set $backend_upstream backend-api; \
|
||||||
|
proxy_pass http://$backend_upstream:8000; \
|
||||||
|
proxy_http_version 1.1; \
|
||||||
|
proxy_set_header Upgrade $http_upgrade; \
|
||||||
|
proxy_set_header Connection "upgrade"; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
|
} \
|
||||||
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
// Declaration for the global variable injected by Vite
|
||||||
|
declare global {
|
||||||
|
const __BUILD_TIME__: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutoUpdateChecker component
|
||||||
|
* Periodically checks if a new version of the app is available on the server.
|
||||||
|
* If a new version is detected, it reloads the page to ensure the user has the latest version.
|
||||||
|
*/
|
||||||
|
export const AutoUpdateChecker = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run this in production
|
||||||
|
if (import.meta.env.DEV) return;
|
||||||
|
|
||||||
|
const checkVersion = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch the version.json file from the server
|
||||||
|
// Add a cache-busting timestamp to the request itself to ensure we get the latest file
|
||||||
|
const response = await fetch(`/version.json?t=${new Date().getTime()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const serverVersion = data.buildTime;
|
||||||
|
|
||||||
|
// Compare server version with the build time injected at compile time
|
||||||
|
// Use a small 1-second threshold to avoid issues with slight build variances
|
||||||
|
if (serverVersion && serverVersion > (__BUILD_TIME__ + 1000)) {
|
||||||
|
const lastReloadedVersion = localStorage.getItem('last_reloaded_version');
|
||||||
|
|
||||||
|
if (lastReloadedVersion !== String(serverVersion)) {
|
||||||
|
console.log('New version detected, reloading...');
|
||||||
|
localStorage.setItem('last_reloaded_version', String(serverVersion));
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.warn('New version detected but already reloaded once. Stopping loop.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking version:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check version on mount
|
||||||
|
checkVersion();
|
||||||
|
|
||||||
|
// Check version when the page becomes visible again (e.g., user returns to the tab)
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
checkVersion();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check version every 5 minutes
|
||||||
|
const interval = setInterval(checkVersion, 1000 * 60 * 5);
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null; // This component doesn't render anything
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ export interface EdgeProject {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
client: string
|
client: string
|
||||||
|
operator_name: string
|
||||||
|
contract_number: string
|
||||||
description?: string
|
description?: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
installer_status?: 'NOT_STARTED' | 'BUILDING' | 'READY' | 'FAILED'
|
installer_status?: 'NOT_STARTED' | 'BUILDING' | 'READY' | 'FAILED'
|
||||||
@@ -16,6 +18,8 @@ export interface EdgeProject {
|
|||||||
export interface CreateProjectRequest {
|
export interface CreateProjectRequest {
|
||||||
name: string
|
name: string
|
||||||
client: string
|
client: string
|
||||||
|
operator_name: string
|
||||||
|
contract_number: string
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ export default function CreateProjectPage() {
|
|||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
client: 'OmniOil',
|
client: '',
|
||||||
|
operator_name: '',
|
||||||
|
contract_number: '',
|
||||||
description: '',
|
description: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,17 +68,43 @@ export default function CreateProjectPage() {
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
Cliente / Operador
|
Cliente / Razón Social
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
placeholder="Ej. Ecopetrol"
|
placeholder="Ej. Ecopetrol S.A."
|
||||||
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all"
|
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all"
|
||||||
value={formData.client}
|
value={formData.client}
|
||||||
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Operador (Identificador ANH)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Ej. ECOPETROL_S_A"
|
||||||
|
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all"
|
||||||
|
value={formData.operator_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, operator_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Número de Contrato
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Ej. CONT-2026-XYZ"
|
||||||
|
className="w-full rounded-lg border border-border bg-input px-4 py-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/50 transition-all"
|
||||||
|
value={formData.contract_number}
|
||||||
|
onChange={(e) => setFormData({ ...formData, contract_number: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
Descripción <span className="normal-case font-normal">(Opcional)</span>
|
Descripción <span className="normal-case font-normal">(Opcional)</span>
|
||||||
|
|||||||
@@ -201,7 +201,13 @@ export default function ProjectDetailsPage() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => {
|
||||||
|
setNewWell({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
});
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}}
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90 text-xs h-8 gap-1.5 shadow-lg shadow-primary/20"
|
className="bg-primary text-primary-foreground hover:bg-primary/90 text-xs h-8 gap-1.5 shadow-lg shadow-primary/20"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom'
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { router } from './app/router'
|
import { router } from './app/router'
|
||||||
import SessionBootstrap from './app/bootstrap/SessionBootstrap'
|
import SessionBootstrap from './app/bootstrap/SessionBootstrap'
|
||||||
|
import { AutoUpdateChecker } from './components/common/AutoUpdateChecker'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -18,6 +19,7 @@ const queryClient = new QueryClient({
|
|||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AutoUpdateChecker />
|
||||||
<SessionBootstrap>
|
<SessionBootstrap>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</SessionBootstrap>
|
</SessionBootstrap>
|
||||||
|
|||||||
@@ -2,12 +2,25 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
const buildTime = new Date().getTime()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__BUILD_TIME__: buildTime,
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react()
|
react(),
|
||||||
|
{
|
||||||
|
name: 'generate-version',
|
||||||
|
writeBundle() {
|
||||||
|
const version = { buildTime };
|
||||||
|
fs.writeFileSync(path.resolve(__dirname, 'dist/version.json'), JSON.stringify(version));
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|||||||
@@ -30,22 +30,37 @@ RUN pnpm build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Optimized Nginx config for PWA (with API proxy)
|
# Optimized Nginx config for PWA (with API proxy and Cache Control)
|
||||||
RUN echo 'server { \
|
RUN echo 'server { \
|
||||||
listen 80; \
|
listen 80; \
|
||||||
location / { \
|
|
||||||
root /usr/share/nginx/html; \
|
root /usr/share/nginx/html; \
|
||||||
index index.html index.htm; \
|
index index.html; \
|
||||||
try_files $uri $uri/ /index.html; \
|
\
|
||||||
|
# Cache Control para el index.html y sw.js (NUNCA cachear) \
|
||||||
|
location ~* ^/(index\.html|sw\.js)$ { \
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; \
|
||||||
} \
|
} \
|
||||||
|
\
|
||||||
|
# Cache Control para assets estáticos (Vite usa hashes) \
|
||||||
|
location /assets/ { \
|
||||||
|
expires 1y; \
|
||||||
|
add_header Cache-Control "public, immutable"; \
|
||||||
|
} \
|
||||||
|
\
|
||||||
|
location / { \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
\
|
||||||
location /api/ { \
|
location /api/ { \
|
||||||
proxy_pass http://backend-api:8000; \
|
resolver 127.0.0.11 valid=30s; \
|
||||||
proxy_http_version 1.1; \
|
set $backend_upstream backend-api; \
|
||||||
proxy_set_header Upgrade $http_upgrade; \
|
proxy_pass http://$backend_upstream:8000; \
|
||||||
proxy_set_header Connection "upgrade"; \
|
proxy_http_version 1.1; \
|
||||||
proxy_set_header Host $host; \
|
proxy_set_header Upgrade $http_upgrade; \
|
||||||
|
proxy_set_header Connection "upgrade"; \
|
||||||
|
proxy_set_header Host $host; \
|
||||||
} \
|
} \
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { lazy, Suspense, useEffect } from 'react'
|
import { lazy, Suspense, useEffect } from 'react'
|
||||||
import { createBrowserRouter, Navigate, useLocation } from 'react-router-dom'
|
import { createBrowserRouter, Navigate, useLocation } from 'react-router-dom'
|
||||||
import LoginPage from '@/features/auth/pages/LoginPage'
|
import LoginPage from '@/features/auth/pages/LoginPage'
|
||||||
import RegisterPage from '@/features/auth/pages/RegisterPage'
|
|
||||||
import FieldLayout from '@/app/layouts/FieldLayout'
|
import FieldLayout from '@/app/layouts/FieldLayout'
|
||||||
import OfflinePage from '@/features/offline/OfflinePage'
|
import OfflinePage from '@/features/offline/OfflinePage'
|
||||||
import { AuthTransitionScreen } from '@/features/auth/components/AuthTransitionScreen'
|
import { AuthTransitionScreen } from '@/features/auth/components/AuthTransitionScreen'
|
||||||
@@ -64,10 +63,6 @@ export const router = createBrowserRouter([
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
element: <LoginPage />,
|
element: <LoginPage />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/register',
|
|
||||||
element: <RegisterPage />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/offline',
|
path: '/offline',
|
||||||
element: <OfflinePage />,
|
element: <OfflinePage />,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
// Declaration for the global variable injected by Vite
|
||||||
|
declare global {
|
||||||
|
const __BUILD_TIME__: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutoUpdateChecker component for PWA
|
||||||
|
* Work together with Service Worker to ensure the app is always up to date.
|
||||||
|
*/
|
||||||
|
export const AutoUpdateChecker = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (import.meta.env.DEV) return;
|
||||||
|
|
||||||
|
const checkVersion = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/version.json?t=${new Date().getTime()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const serverVersion = data.buildTime;
|
||||||
|
|
||||||
|
// Use a small 1-second threshold to avoid issues with slight build variances
|
||||||
|
if (serverVersion && serverVersion > (__BUILD_TIME__ + 1000)) {
|
||||||
|
const lastReloadedVersion = localStorage.getItem('last_reloaded_version');
|
||||||
|
|
||||||
|
if (lastReloadedVersion !== String(serverVersion)) {
|
||||||
|
console.log('New version detected for PWA, reloading...');
|
||||||
|
localStorage.setItem('last_reloaded_version', String(serverVersion));
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
console.warn('New version detected but already reloaded once. Stopping loop.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking version:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkVersion();
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
checkVersion();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkVersion, 1000 * 60 * 15); // Check less frequently in PWA to save battery
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { apiClient } from '@/lib/api/client'
|
import { apiClient } from '@/lib/api/client'
|
||||||
import type { LoginFormData } from '../schemas/login-schema'
|
import type { LoginFormData } from '../schemas/login-schema'
|
||||||
import type { RegisterFormData } from '../schemas/register-schema'
|
|
||||||
|
|
||||||
interface ProjectSummary {
|
interface ProjectSummary {
|
||||||
id: string
|
id: string
|
||||||
@@ -21,10 +20,3 @@ export async function loginApi(data: LoginFormData): Promise<LoginResponse> {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerApi(data: RegisterFormData): Promise<{ id: string, email: string }> {
|
|
||||||
return apiClient<{ id: string, email: string }>('/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ describe('PWA LoginPage', () => {
|
|||||||
expect(container?.textContent).toContain('Iniciar turno y cargar parte')
|
expect(container?.textContent).toContain('Iniciar turno y cargar parte')
|
||||||
expect(container?.textContent).toContain('Offline primero')
|
expect(container?.textContent).toContain('Offline primero')
|
||||||
expect(container?.textContent).toContain('Pozo, bateria o ruta')
|
expect(container?.textContent).toContain('Pozo, bateria o ruta')
|
||||||
|
expect(container?.textContent).toContain('Si todavia no te habilitaron para la cuadrilla, pedi el alta a un administrador.')
|
||||||
expect(container?.textContent).not.toContain('OmniOil Admin')
|
expect(container?.textContent).not.toContain('OmniOil Admin')
|
||||||
expect(container?.textContent).not.toContain('Consola administrativa')
|
expect(container?.textContent).not.toContain('Consola administrativa')
|
||||||
expect(container?.textContent).not.toContain('Acceso administrativo')
|
expect(container?.textContent).not.toContain('Acceso administrativo')
|
||||||
expect(container?.textContent).not.toContain('Ingresar al panel admin')
|
expect(container?.textContent).not.toContain('Ingresar al panel admin')
|
||||||
|
expect(container?.textContent).not.toContain('Pedir alta de campo')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useLocation, Link } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { loginSchema, type LoginFormData } from '../schemas/login-schema'
|
import { loginSchema, type LoginFormData } from '../schemas/login-schema'
|
||||||
@@ -358,11 +358,8 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-border text-center">
|
<div className="pt-4 border-t border-border text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
¿Todavia no te habilitaron para la cuadrilla?{' '}
|
Si todavia no te habilitaron para la cuadrilla, pedi el alta a un administrador.
|
||||||
<Link to="/register" className="text-emerald-300 font-semibold hover:text-emerald-200 transition-colors">
|
|
||||||
Pedir alta de campo
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { registerSchema, type RegisterFormData } from '../schemas/register-schema'
|
|
||||||
import { registerApi } from '../api/auth-api'
|
|
||||||
import { Loader2, Droplets } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [isSuccess, setIsSuccess] = useState(false)
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<RegisterFormData>({
|
|
||||||
resolver: zodResolver(registerSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = async (data: RegisterFormData) => {
|
|
||||||
setError(null)
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
await registerApi(data)
|
|
||||||
setIsSuccess(true)
|
|
||||||
setTimeout(() => navigate('/login'), 2000)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Error al registrar usuario')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[var(--background)] px-4">
|
|
||||||
<div className="w-full max-w-md space-y-8">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="flex items-center justify-center gap-3 mb-2">
|
|
||||||
<Droplets className="h-10 w-10 text-cyan-400" />
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white">
|
|
||||||
OMNIOIL
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
Crea tu cuenta de operador
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card */}
|
|
||||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-8 shadow-2xl shadow-cyan-500/5">
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-6">
|
|
||||||
Registro de Usuario
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{isSuccess ? (
|
|
||||||
<div className="rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-6 text-center">
|
|
||||||
<p className="text-emerald-400 font-medium">¡Cuenta creada con éxito!</p>
|
|
||||||
<p className="text-xs text-emerald-400/70 mt-2">Redirigiendo al login...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
|
||||||
{/* Full Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="full_name" className="text-sm font-medium text-[var(--foreground)]">
|
|
||||||
Nombre completo
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="full_name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Juan Pérez"
|
|
||||||
className="w-full rounded-lg border border-[var(--border)] bg-[var(--input)] px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all"
|
|
||||||
{...register('full_name')}
|
|
||||||
/>
|
|
||||||
{errors.full_name && (
|
|
||||||
<p className="text-xs text-red-400">{errors.full_name.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="email" className="text-sm font-medium text-[var(--foreground)]">
|
|
||||||
Correo electrónico
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="ejemplo@omnioil.com"
|
|
||||||
className="w-full rounded-lg border border-[var(--border)] bg-[var(--input)] px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all"
|
|
||||||
{...register('email')}
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="text-xs text-red-400">{errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="password" className="text-sm font-medium text-[var(--foreground)]">
|
|
||||||
Contraseña
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
className="w-full rounded-lg border border-[var(--border)] bg-[var(--input)] px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all"
|
|
||||||
{...register('password')}
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="text-xs text-red-400">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-cyan-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-[var(--background)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Creando cuenta...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Registrarse'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="text-center pt-2">
|
|
||||||
<Link to="/login" className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors">
|
|
||||||
¿Ya tienes cuenta? Inicia sesión aquí
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-600">
|
|
||||||
OMNIOIL ANALYTICS v1.0 — ANH Colombia
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
export const registerSchema = z.object({
|
|
||||||
email: z.string().email('Correo electrónico inválido'),
|
|
||||||
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres'),
|
|
||||||
full_name: z.string().min(3, 'El nombre debe tener al menos 3 caracteres'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type RegisterFormData = z.infer<typeof registerSchema>
|
|
||||||
@@ -3,7 +3,7 @@ import { resolveAuthRoute } from '@/features/auth/lib/domain-routing'
|
|||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
||||||
const LOGIN_PATH = '/login'
|
const LOGIN_PATH = '/login'
|
||||||
const AUTH_BYPASS_ENDPOINTS = ['/auth/login', '/auth/register', '/auth/refresh']
|
const AUTH_BYPASS_ENDPOINTS = ['/auth/login', '/auth/refresh']
|
||||||
|
|
||||||
interface FetchOptions extends RequestInit {
|
interface FetchOptions extends RequestInit {
|
||||||
token?: string | null
|
token?: string | null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RouterProvider } from 'react-router-dom'
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { router } from './app/router'
|
import { router } from './app/router'
|
||||||
import SessionBootstrap from './app/bootstrap/SessionBootstrap'
|
import SessionBootstrap from './app/bootstrap/SessionBootstrap'
|
||||||
|
import { AutoUpdateChecker } from './components/common/AutoUpdateChecker'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -21,6 +22,7 @@ registerSW({ immediate: true })
|
|||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AutoUpdateChecker />
|
||||||
<SessionBootstrap>
|
<SessionBootstrap>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</SessionBootstrap>
|
</SessionBootstrap>
|
||||||
|
|||||||
@@ -3,12 +3,25 @@ import react from '@vitejs/plugin-react'
|
|||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
const buildTime = new Date().getTime()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__BUILD_TIME__: buildTime,
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
{
|
||||||
|
name: 'generate-version',
|
||||||
|
writeBundle() {
|
||||||
|
const version = { buildTime };
|
||||||
|
fs.writeFileSync(path.resolve(__dirname, 'dist/version.json'), JSON.stringify(version));
|
||||||
|
}
|
||||||
|
},
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
injectRegister: 'auto',
|
injectRegister: 'auto',
|
||||||
|
|||||||
@@ -29,22 +29,27 @@ impl Generator {
|
|||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
operador: String,
|
operador: String,
|
||||||
contrato: String,
|
contrato: String,
|
||||||
) -> Result<(String, String), Box<dyn std::error::Error>> {
|
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let start_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
let start_time = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||||
let end_time = date.and_hms_opt(23, 59, 59).unwrap().and_utc();
|
let end_time = date.and_hms_opt(23, 59, 59).unwrap().and_utc();
|
||||||
|
|
||||||
// 1. Query High Frequency Data
|
// 1. Query High Frequency Data — Filtrado por Operador y Contrato (Multitenant via Projects)
|
||||||
let high_freq_rows = sqlx::query(
|
let high_freq_rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT t.time, a.code, t.data
|
SELECT t.time, a.code, t.data
|
||||||
FROM telemetry_raw t
|
FROM telemetry_raw t
|
||||||
JOIN edge_assets a ON t.asset_id = a.id
|
JOIN edge_assets a ON t.asset_id = a.id
|
||||||
|
JOIN edge_projects p ON a.project_id = p.id
|
||||||
WHERE t.time BETWEEN $1 AND $2
|
WHERE t.time BETWEEN $1 AND $2
|
||||||
|
AND p.operator_name = $3
|
||||||
|
AND p.contract_number = $4
|
||||||
ORDER BY t.time ASC
|
ORDER BY t.time ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&start_time)
|
.bind(&start_time)
|
||||||
.bind(&end_time)
|
.bind(&end_time)
|
||||||
|
.bind(&operador)
|
||||||
|
.bind(&contrato)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ mod uploader;
|
|||||||
|
|
||||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::{Row, postgres::PgPoolOptions};
|
||||||
use std::env;
|
use std::env;
|
||||||
use dotenv::dotenv;
|
use dotenv::dotenv;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -11,7 +11,7 @@ use generator::Generator;
|
|||||||
use uploader::Uploader;
|
use uploader::Uploader;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
@@ -48,41 +48,61 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_generation_task() -> Result<(), Box<dyn std::error::Error>> {
|
async fn run_generation_task() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let operador = env::var("OPERADOR_NOMBRE").unwrap_or_else(|_| "OMNIOIL".to_string());
|
// Obtener todos los pares (operador, contrato) desde los proyectos activos
|
||||||
let contrato = env::var("CONTRATO_NUMERO").unwrap_or_else(|_| "C-123".to_string());
|
let groups = sqlx::query(
|
||||||
|
"SELECT DISTINCT operator_name, contract_number FROM edge_projects WHERE is_active = true"
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if groups.is_empty() {
|
||||||
|
tracing::warn!("No active assets with operator_name found in DB. Skipping generation.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Fecha reporte = Ayer
|
// Fecha reporte = Ayer
|
||||||
let date = Utc::now().naive_utc().date().pred_opt().unwrap();
|
let date = Utc::now().naive_utc().date().pred_opt().unwrap();
|
||||||
|
|
||||||
tracing::info!("Generating report for date: {}", date);
|
for row in groups {
|
||||||
|
let operador: String = row.get("operator_name");
|
||||||
|
let contrato: String = row.get("contract_number");
|
||||||
|
|
||||||
let (json_content, hash) = Generator::generate_daily_report(
|
tracing::info!("Generating report for operator: {}, contract: {}, date: {}", operador, contrato, date);
|
||||||
&pool,
|
|
||||||
date,
|
|
||||||
operador.clone(),
|
|
||||||
contrato.clone()
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
// Nomenclatura: NOMBREOPERADOR_CONTRATONUMERO_FECHA(dd-mmm-yyyy).json
|
// El generador filtrará internamente por estos valores
|
||||||
let filename = format!("{}_{}_{}.json",
|
match Generator::generate_daily_report(
|
||||||
operador,
|
&pool,
|
||||||
contrato,
|
date,
|
||||||
date.format("%d-%b-%Y").to_string().to_uppercase()
|
operador.clone(),
|
||||||
);
|
contrato.clone()
|
||||||
|
).await {
|
||||||
|
Ok((json_content, hash)) => {
|
||||||
|
// Nomenclatura: NOMBREOPERADOR_CONTRATONUMERO_FECHA(dd-mmm-yyyy).json
|
||||||
|
let filename = format!("{}_{}_{}.json",
|
||||||
|
operador.replace(" ", "_"),
|
||||||
|
contrato.replace(" ", "_"),
|
||||||
|
date.format("%d-%b-%Y").to_string().to_uppercase()
|
||||||
|
);
|
||||||
|
|
||||||
tracing::info!("Report generated. Hash: {}. Uploading {}...", hash, filename);
|
tracing::info!("Report generated. Hash: {}. Uploading {}...", hash, filename);
|
||||||
|
|
||||||
let uploader = Uploader::new().await;
|
let uploader = Uploader::new().await;
|
||||||
uploader.ensure_bucket().await?;
|
uploader.ensure_bucket().await?;
|
||||||
uploader.upload_json(&filename, json_content).await?;
|
uploader.upload_json(&filename, json_content).await?;
|
||||||
|
|
||||||
tracing::info!("Upload successful!");
|
tracing::info!("Upload successful for {}!", filename);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to generate report for {} / {}: {}", operador, contrato, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ impl Uploader {
|
|||||||
Self { client, bucket }
|
Self { client, bucket }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ensure_bucket(&self) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn ensure_bucket(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let _ = self.client.create_bucket()
|
let _ = self.client.create_bucket()
|
||||||
.bucket(&self.bucket)
|
.bucket(&self.bucket)
|
||||||
.send()
|
.send()
|
||||||
@@ -32,7 +32,7 @@ impl Uploader {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn upload_json(&self, filename: &str, content: String) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn upload_json(&self, filename: &str, content: String) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
self.client.put_object()
|
self.client.put_object()
|
||||||
.bucket(&self.bucket)
|
.bucket(&self.bucket)
|
||||||
.key(filename)
|
.key(filename)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub struct EdgeProject {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub client: String,
|
pub client: String,
|
||||||
|
pub operator_name: String,
|
||||||
|
pub contract_number: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
@@ -28,6 +30,8 @@ pub struct EdgeProject {
|
|||||||
pub struct CreateProjectRequest {
|
pub struct CreateProjectRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub client: String,
|
pub client: String,
|
||||||
|
pub operator_name: String,
|
||||||
|
pub contract_number: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub metadata: Option<serde_json::Value>,
|
pub metadata: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ pub fn generate_custom_binary(
|
|||||||
output_path: &Path,
|
output_path: &Path,
|
||||||
config: &Value,
|
config: &Value,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// 1. Convertir JSON config a TOML simple
|
// 1. Obtener la clave obligatoria del entorno (solo requerida en el servidor para generar)
|
||||||
|
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
|
||||||
|
.map_err(|_| "OMNIOIL_MASTER_SECRET must be set for binary generation")?;
|
||||||
|
|
||||||
|
// 2. Convertir JSON config a TOML simple
|
||||||
let mut config_toml = String::new();
|
let mut config_toml = String::new();
|
||||||
if let Some(obj) = config.as_object() {
|
if let Some(obj) = config.as_object() {
|
||||||
for (section, values) in obj {
|
for (section, values) in obj {
|
||||||
@@ -53,14 +57,11 @@ pub fn generate_custom_binary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Encriptar con AES-256-GCM
|
// 3. Encriptar con AES-256-GCM usando la clave original
|
||||||
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
|
|
||||||
.unwrap_or_else(|_| "dev_master_secret_omnioil_2026".to_string());
|
|
||||||
let key_bytes = derive_key(&master_secret);
|
let key_bytes = derive_key(&master_secret);
|
||||||
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
|
let cipher = Aes256Gcm::new_from_slice(&key_bytes)
|
||||||
.map_err(|e| format!("Cipher Initialization Error: {}", e))?;
|
.map_err(|e| format!("Cipher Initialization Error: {}", e))?;
|
||||||
|
|
||||||
// Generar un Nonce único por cada binario generado
|
|
||||||
let mut nonce_bytes = [0u8; 12];
|
let mut nonce_bytes = [0u8; 12];
|
||||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
@@ -69,20 +70,28 @@ pub fn generate_custom_binary(
|
|||||||
.encrypt(nonce, config_toml.as_bytes())
|
.encrypt(nonce, config_toml.as_bytes())
|
||||||
.map_err(|e| format!("Encryption failed: {}", e))?;
|
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
// Estructura del Payload: [Nonce (12)] + [Ciphertext (N)]
|
// 4. Ofuscar el Master Secret para viajar en el binario sin estar en texto plano
|
||||||
|
// Usamos XOR con 0x5A para ofuscación simple (mismo patrón que BAKED_CONFIG)
|
||||||
|
let obfuscated_secret: Vec<u8> = master_secret.as_bytes().iter().map(|b| b ^ 0x5A).collect();
|
||||||
|
let secret_len = obfuscated_secret.len();
|
||||||
|
if secret_len > 255 {
|
||||||
|
return Err("Master secret is too long".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estructura del Payload: [Nonce (12)] + [SecretLen (1)] + [ObfuscatedSecret (N)] + [Ciphertext (M)]
|
||||||
let mut payload = Vec::new();
|
let mut payload = Vec::new();
|
||||||
payload.extend_from_slice(&nonce_bytes);
|
payload.extend_from_slice(&nonce_bytes);
|
||||||
|
payload.push(secret_len as u8);
|
||||||
|
payload.extend_from_slice(&obfuscated_secret);
|
||||||
payload.extend_from_slice(&ciphertext);
|
payload.extend_from_slice(&ciphertext);
|
||||||
|
|
||||||
let config_len = payload.len() as u32;
|
let total_payload_len = payload.len() as u32;
|
||||||
|
|
||||||
// 3. Leer plantilla original
|
// 5. Leer plantilla y construir binario final
|
||||||
let mut template_content = Vec::new();
|
let mut template_content = Vec::new();
|
||||||
fs::File::open(template_path)?.read_to_end(&mut template_content)?;
|
fs::File::open(template_path)?.read_to_end(&mut template_content)?;
|
||||||
|
|
||||||
// 4. Construir binario final
|
let length_bytes = total_payload_len.to_le_bytes();
|
||||||
// Estructura Footer: [payload] + [4 bytes len] + [8 bytes magic "OMNIOIL\0"]
|
|
||||||
let length_bytes = config_len.to_le_bytes();
|
|
||||||
|
|
||||||
if let Some(parent) = output_path.parent() {
|
if let Some(parent) = output_path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
@@ -90,21 +99,20 @@ pub fn generate_custom_binary(
|
|||||||
|
|
||||||
let mut out_file = fs::File::create(output_path)?;
|
let mut out_file = fs::File::create(output_path)?;
|
||||||
out_file.write_all(&template_content)?;
|
out_file.write_all(&template_content)?;
|
||||||
out_file.write_all(&payload)?;
|
out_file.write_all(&payload)?; // Todo el bloque (nonce, secret, ciphertext)
|
||||||
out_file.write_all(&length_bytes)?;
|
out_file.write_all(&length_bytes)?;
|
||||||
out_file.write_all(MAGIC)?;
|
out_file.write_all(MAGIC)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Versión de la función que permite especificar la ruta (útil para tests)
|
/// Versión de la función que permite especificar la ruta (útil para tests)
|
||||||
pub fn extract_overlay_from_file(path: &Path) -> Option<String> {
|
pub fn extract_overlay_from_file(path: &Path) -> Option<String> {
|
||||||
let mut file = fs::File::open(path).ok()?;
|
let mut file = fs::File::open(path).ok()?;
|
||||||
let mut buffer = Vec::new();
|
let mut buffer = Vec::new();
|
||||||
file.read_to_end(&mut buffer).ok()?;
|
file.read_to_end(&mut buffer).ok()?;
|
||||||
|
|
||||||
if buffer.len() < 12 + 4 + 8 {
|
if buffer.len() < 12 + 1 + 4 + 8 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,30 +122,37 @@ pub fn extract_overlay_from_file(path: &Path) -> Option<String> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leer longitud (4 bytes antes del magic)
|
// Leer longitud total del payload (4 bytes antes del magic)
|
||||||
let len_start = footer_start - 4;
|
let len_start = footer_start - 4;
|
||||||
let mut len_bytes = [0u8; 4];
|
let mut len_bytes = [0u8; 4];
|
||||||
len_bytes.copy_from_slice(&buffer[len_start..footer_start]);
|
len_bytes.copy_from_slice(&buffer[len_start..footer_start]);
|
||||||
let payload_len = u32::from_le_bytes(len_bytes) as usize;
|
let total_payload_len = u32::from_le_bytes(len_bytes) as usize;
|
||||||
|
|
||||||
if len_start < payload_len {
|
let payload_start = len_start - total_payload_len;
|
||||||
|
if payload_start >= len_start {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leer payload encriptado
|
// --- Extracción del Payload por segmentos ---
|
||||||
let payload_start = len_start - payload_len;
|
// [Nonce (12)] + [SecretLen (1)] + [ObfuscatedSecret (N)] + [Ciphertext (M)]
|
||||||
let payload = &buffer[payload_start..len_start];
|
|
||||||
|
|
||||||
if payload.len() < 12 {
|
// Nonce
|
||||||
return None; // Mínimo nonce (12)
|
let nonce_bytes = &buffer[payload_start..payload_start + 12];
|
||||||
|
|
||||||
|
// Secret Length & Secret
|
||||||
|
let secret_len = buffer[payload_start + 12] as usize;
|
||||||
|
if payload_start + 13 + secret_len > len_start {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
|
let obfuscated_secret = &buffer[payload_start + 13..payload_start + 13 + secret_len];
|
||||||
|
|
||||||
let nonce_bytes = &payload[..12];
|
// Ciphertext
|
||||||
let ciphertext = &payload[12..];
|
let ciphertext = &buffer[payload_start + 13 + secret_len..len_start];
|
||||||
|
|
||||||
// Desencriptar
|
// 1. Reconstruir Master Secret desde el ofuscado (XOR 0x5A)
|
||||||
let master_secret = std::env::var("OMNIOIL_MASTER_SECRET")
|
let master_secret: String = obfuscated_secret.iter().map(|b| (b ^ 0x5A) as char).collect();
|
||||||
.unwrap_or_else(|_| "dev_master_secret_omnioil_2026".to_string());
|
|
||||||
|
// 2. Derivar llave y desencriptar
|
||||||
let key_bytes = derive_key(&master_secret);
|
let key_bytes = derive_key(&master_secret);
|
||||||
let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?;
|
let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?;
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
|||||||
Reference in New Issue
Block a user