lunes, 3 de noviembre de 2025

WEBPROPv2 (XVII). Tareas de manternimiento (II). Clave SSH. Scripts de mantenimiento (copiar ficheros, arrancar servicios etc)

1. Clave ssh

1.1 Creación de una clave ssh para conectarnos al servidor remoto

Primeramente crearemos una carpeta (aunque no esnecesario). Utilizaremsos el tipo ed25519.  

Se le dará el nombre que queramos en este caso "srv01_ssh_key" y opcionalmente se puede añadir un comentario que se añadirá dicho comentario al final en el fichero de la clave pública. El comentario es: "Clave Srv01"


mkdir -p /home/myuser/keys

ssh-keygen -t ed25519 -f /home/myuser/keys/srv01_ssh_key -C "Clave Srv01"

Se creará una clave privada (srv01_ssh_key) y una clave pública (srv01_ssh_key.pub). Hay que tener cuidado de no compartir la clave privada.

Durante el proceso te pide una contraseña, que debes guardar

1.2. Uso de la clave ssh

Cada vez que ejecutemos un comando por ejemplo

ssh -i /home/myuser/keys/srv01_ssh_key usuario@servidor.com

Nos pedirá la contraseña.

Si queremos conservar la contraseña en memoria en cada conexión :

eval "$(ssh-agent -s)"
ssh-add /home/myuser/keys/srv01_ssh_key


1.3. Copiar la clave pública al servidor remoto

Se puede copiar utilizando ssh-copy-id o cat + ssh

# Si se dispone de ssh-copu-id
ssh-copy-id -i /home/myuser/keys/srv01_ssh_key usuario_remoto@IP_REMOTA

#Sinó
cat /home/myuser/keys/srv01_ssh_key | ssh usuario_remoto@IP_REMOTA "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"


1.4. Ejecutar en el servidor remoto

Podemos ejecutar ssh, scp o rsync usando esta clave:

# 1. ssh
ssh -i /home/myuser/keys/srv01_ssh_key usuario_remoto@IP_REMOTA

# 2. scp
scp -i /home/myuser/keys/srv01_ssh_key archivo usuario_remoto@IP_REMOTA:/ruta/

# 3. rsync
rsync -e "ssh -i /home/myuser/keys/srv01_ssh_key" ...

1.5. Configurar ~/.ssh/config con alias y ruta de la clave par acceder al servidor remoto

Así podemos evitar usar la opción -i. Veamos el fichero ~/.ssh/config:

# Read more about SSH config files: https://linux.die.net/man/5/ssh_config

# Servidor GLI i ara té la web de l'Ajuntament
Host srv01
    HostName IP_REMOTA
    User usuario_remoto
    IdentityFile /home/myuser/keys/srv01_ssh_key
    IdentitiesOnly yes
    Port 22

Y ahora ejecutando

ssh IP_REMOTA 

nos pediará la contraseña de la clave privada y entramos


2. Script de arrancar servicios remotos










martes, 28 de octubre de 2025

WEBPROPv2 (XVI). Solución de errores(3). Un registro de Osticket (id=769) da error al mostrarse. HTMX problem

1. Causa del problema

htmx cuando hace un post, si el elemento que lo realiza está dentro de un form entonces envia todos los datos del form en el request dentro de una estructura "FormData"

Si el form tiene muchso datos y pasamos for nginx (openresty), entonces hacemos un "overflow" del caché del mismo.

ChatGPT y Claude plantean soluciones QUE NO FUNCIONAN que es añadirle algunos de estos parámetros al htmx (aplicados a un botón o un anchor):

form = 'none' 
hx-include="none" 
hx-include="[data-include='never']"

Al final utilizando Claude lo que se hace es realizar un fetch, para simplificar creamos una función para utilizarla igual:

/**
 * Fetch data from the given endpoint and update the target element with the response HTML.
 * @param {*} endpoint 
 * @param {*} data 
 * @param {*} targetSelector 
 * @returns 
 */
async function fetchAndUpdate(endpoint, data, targetSelector) {
	console.log("executing fetchAndUpdate with:", endpoint, JSON.stringify(data), targetSelector);
    try {
        const response = await fetch(endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(data)
        });
        
        const html = await response.text();
        const target = document.querySelector(targetSelector);
        
        if (target) {
            target.innerHTML = html;
        } else {
            console.warn(`Target element "${targetSelector}" not found`);
        }
        
        return html;
    } catch (error) {
        console.error('Fetch error:', error);
        throw error;
    }
}

y luego la llamamos así:

// ✅ Define the "value" property
  set value(newValue) {
    
	const tab_id= sessionStorage.getItem("id_tab");
        const mydata= { sql_id: newValue, my_id: this._parentId, tab_id: tab_id || "" };		
	fetchAndUpdate( this.url, mydata, this.target );
  }



martes, 21 de octubre de 2025

WEBPROPv2 (XV). Actualizando versiones.

1. Comprobaciones previas:

a. Copiar programas desde el ordenador de desarrollo al de producción

Para ello utilizaremos el FileZilla (también sepuede utilizar ssh)

Se deberán copiar todos los ficheros y carpetas de la carpeta softprop de desarrollo excepto las carpetas:

  • .pythest_cache
  • .vscode
  • __pycache__
  • _provetes
  • camel2snake
  • venv_softprop
  • zz_copies_seg_zip
Si se hubiera tocado parte de la configuración de openresty como nginx.conf,algún modulo lua, certificados etcentonces se copiará:
  • openresty/v04/* a /usr/local/openresty/nginx/conf/

b. Copiar y revisar el contenido de _exportacio

Ahora hay que comprobar que las rutas a los servidores y entorno virtual python sean correctas en las versiones que hemos copiado y modificado adecuadamente en _exportació

1. En autentication/xmopenresty.py verifcar que está activa la parte remota y el entorno virtual remoto:

#!/home//informatica/eduApps/softprop/venv_softprop/bin/python3

#????????###################################################
# CANVIAR LOCAL:
#my_host = "192.168.XX.XX"
#my_port = 5001
#------------------------------
# CANVIAR REMOT:
my_host = "192.168.YY.YY"
my_port = 5001
####################################################

2. En menus/menu_main.py verificar lo mismo

#!/home/informatica/eduApps/softprop/venv_softprop/bin/python3

#????????###################################################
# CANVIAR LOCAL:
#my_host = "edu.poblacion.es"
#my_port = 5000
#------------------------------
# CANVIAR REMOT:
my_host = "proves.poblacion.es"
my_port = 5000
####################################################

Y en openresty/v04/nginx.conf verificar:

http {
    #????????============================================================================
	# CANVIAR LOCAL: 
	# --- Constants defined with map
    #map "" $MY_SERVER        { default 192.168.10.5; }
    #map "" $MY_SERVER_NAME   { default edu.tavernes.es; }
    #map "" $MY_AUTH_URL      { default https://192.168.10.5:5001/auth; }
    #map "" $MY_CONF_PATH     { default /usr/local/openresty/nginx/conf; }
    #map "" $SESSION_EXPIRATION { default 3600; }  # 1 hour
	# Creamos una bateria de servidores para softprop con un solo servidor
    #upstream softpropsrv {
	#	server 192.168.10.5:5000;
	#	keepalive 32; # NOU 2025-10-6 (2)
    #}   
	#-----------------------------------------
	#  CANVIAR REMOT!
	# --- Constants defined with map
    map "" $MY_SERVER        { default 192.168.YY.YY; }
    map "" $MY_SERVER_NAME   { default proves.localidad.es; }
    map "" $MY_AUTH_URL      { default https://192.168.YY.YY:5001/auth; }
    map "" $MY_CONF_PATH     { default /usr/local/openresty/nginx/conf; }
    map "" $SESSION_EXPIRATION { default 3600; }  # 1 hour
	# Creamos una bateria de servidores para softprop con un solo servidor
    upstream softpropsrv {
		server 192.168.YY.YY:5000;
		keepalive 32; # NOU 2025-10-6 (2)
    }   
	# fi: CANVIAR
	#============================================================================
	

Y se copiara desde el servidor de desarrollo al de producción
  • _exportacio/authentication/xmopenresty.py a authentication/xmopenresty.py
  • _exportacio/menus/menu_main.py a menuis/menu_main.py
  • _exportacio/openresty/v04/nginx.conf a /usr/local/openresty/nginx/conf/nginx.conf
Si ppor algun motivo los ficheros de servicios se hubieran perdido, en _exportacio/services se encuentran dichos ficheros

c. Rearrancar los siguientes servicios:

  1. openresty
  2. python_menus_mnu_main_py.service
  3. python_authentication_xmopenresty_py.service
Se muestran los comandos para rearrancar cada uno de ellos:

sudo systemctl restart openresty
sudo systemctl restart python_menus_mnu_main_py.service
sudo systemctl restart python_authentication_xmopenresty_py.service

Para ver el estado de cada uno uy ver si estń arrancados se ejecutará esta orden:

systemctl list-units --type=service | grep -E 'openresty|python'

Si no estuvieran definidos estos servicios, se consultará a esta entrada.





viernes, 17 de octubre de 2025

WEBPROPv2 (XIV). Ampliar las funcionalidades. Instalar libreoffice (y unoconv ->No)

 1. Introducción

Para poder visualizar ficheros se requiere tener un conversor de ficheros doc, docx, xls ,odt ... a html para poderlos visualizar por tanto se requiere que:

  1. Se instale unoconv y libreoffice
  2. Crear el servicio de libreoffice para que sea mas rápida la conversión
  3. Ejecutar el servicio
Para instalr estos ficheros en Ubuntu (ojo la opción --fix-missing es por si no puede descargar algun paquete):

sudo apt update --fix-missing
sudo apt install unoconv libreoffice --fix-missing

Y para crear  el servicio:

Ejecutamos:

sudo nano /etc/systemd/system/libreoffice-listener.service


Y le damos este contenido:

[Unit]
Description=LibreOffice headless listener for unoconv
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/libreoffice --headless --nologo --nofirststartwizard  --norestore --accept="socket,host=127.0.0.1,port=2002;urp;"
Restart=always
RestartSec=5

# Opcional: ejecutar como un usuario no root
User=libreoffice
Environment="HOME=/tmp"

[Install]
WantedBy=multi-user.target


Y como lo ejecutamos como el usuario libreoffice, tenemos que crearlo:

sudo useradd -r -s /bin/false libreoffice
sudo mkdir /tmp/libreoffice
sudo chown libreoffice:libreoffice /tmp/libreoffice

Ahora toca arrancar el servicio:

sudo systemctl daemon-reload
sudo systemctl enable --now libreoffice-listener.service


Y verificamos que esté ctivo el servicio:

systemctl status libreoffice-listener.service

Y que funcione la conversión:

unoconv -f html -d document test.docx


que crea el fichero test,html al convertir el documento (existente) text.docx


2. Otros recursos necesarios.
Para que libeoffice funcione bien hay que instalar algunos programas en el servidor:







lunes, 13 de octubre de 2025

WEBPROPv2 (XIII). Tareas de manternimiento (I). Instalar librerías.

 1. Instalar nuevas dependencias en el entorno virtual

Primeramente habrá que saber que dependencias instalar. Para ello nos vamos al entorno virtual de la máquina de desarrollo y creamos el fichero "requirements.txt"

1
2
3
4
5
6
7
8
# 1. Primeramente vamos a la carpeta que contiene la carpeta del entorno virtual
cd softprop

#2. Activamos el entrono que esta en nuestro caso en venv_softprop
source venv_softprop/bin/activate

#3. Creamos el fichero de la lista de librerías
pip freeze > requirements.txt


Ahora hay que copiar el fichero "requirements.txt" en el host remoto de produccón a la carpeta padre que contiene la carpeta del entrono virtual. En mi caso lo he hecho con Filezilla

Nos conectamos con ssh con el servidor remoto de producción y hacemos la misma operación pero al revés:

1
2
3
4
5
6
7
8
# 1. Primeramente vamos a la carpeta que contiene la carpeta del entorno virtual
cd softprop

#2. Activamos el entrono que esta en nuestro caso en venv_softprop
source venv_softprop/bin/activate

#3. Creamos el fichero de la lista de librerías
pip install -r requirements.txt


Si queremos actualizar todas las librerías del entorno virtual:

pip list --outdated --format=columns | tail -n +3 | awk '{print $1}' | xargs -n1 pip install -U




 


domingo, 12 de octubre de 2025

COPIAR CARPETAS pero evitando duplicados internos y excluyendo algunas carpetas y extensiones de ficheros

 Introducción

Lo vamos a hacer en 2 pasos:

  1. Detectaremos duplicados por nombre y tamaño (excluyendo algunas carpetas y extensiones de ficheros) creando un fichero con la lista de duplicados
  2. Copiaremos los ficheros excluyendo los ficheros de la lista guadada en el fichero generado anteriormente y las carpetas y ficheros excluidos por extensión

1. Generar una lista de ficheros duplicados:


  • Las líneas 35 a 39 indican las carpetas a excluir
  • Las lÍneas 42 a 44 indica las extensiones de ficheros a excluir
  • Al final nos da el nombre del fichero generado
  • Para ejecutar el script hay que pasarle la carpeta a analizar
Ejecutamos este  script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#!/bin/bash
# buscar_duplicados_exacto_final.sh
# Autor: Ximo Dante con IA
# Busca archivos con el mismo nombre y tamaño exacto,
# excluyendo carpetas del sistema y archivos temporales.
# Marca con "#" las cabeceras y el fichero más superficial.

set -euo pipefail
IFS=$'\n'

BASE_DIR="${1:-}"

if [[ -z "$BASE_DIR" ]]; then
  echo "❌ Uso: $0 /ruta/a/carpeta"
  exit 1
fi
if [[ ! -d "$BASE_DIR" ]]; then
  echo "❌ No existe el directorio: $BASE_DIR"
  exit 1
fi

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
OUT="duplicados_final_${TIMESTAMP}.txt"

echo "🔍 Buscando duplicados exactos en: $BASE_DIR"
echo "📝 Informe: $OUT"
echo

# ------------------------------------------------------------
# 1️⃣ Buscar y procesar con find + awk (sin bucles)
# ------------------------------------------------------------
find "$BASE_DIR" \
  \( -type d \( \
      -name ".*" -o -name "\$*" \
      -o -iname "Archivos de programa" -o -iname "Descargas" -o -iname "Downloads" \
      -o -iname "Documents and Settings" -o -iname "Logs" -o -iname "PerfLogs" \
      -o -iname "Program Files" -o -iname "Program Files (x86)" -o -iname "ProgramData" \
      -o -iname "Recovery" -o -iname "System Volume Information" -o -iname "Temp" \
      -o -iname "Users" -o -iname "Windows" -o -iname "Windows10Upgrade" \
    \) -prune \) \
  -o -type f \
     ! -iname "*.exe" ! -iname "*.log" ! -iname "*.dll" ! -iname "*.sys"\
     ! -iname "*.part" ! -iname "*.crdownload" ! -iname "*.tmp" \
     ! -iname "*.partial" ! -iname "*.download" ! -iname "*!qB*" ! -iname "*.aria2" \
  -printf '%f\t%s\t%p\n' 2>/dev/null \
| sort -k1,1 -k2,2n \
| awk -F'\t' -v base="$BASE_DIR" '
  function depth(path,   nf, nb, a, b) {
    nf = split(path, a, "/");
    nb = split(base, b, "/");
    return nf - nb - 1;
  }

  {
    name=$1; size=$2; path=$3;
    # Ignorar temporales (~$, _~, ~.)
    if (name ~ /^~[$]|^_~|^~\./) next;
    key = name "|" size;
    files[key] = files[key] ? files[key] RS path : path;
  }
  END {
    dup_groups=0;
    for (k in files) {
      n = split(files[k], arr, "\n");
      if (n > 1) {
        dup_groups++;
        split(k, parts, "|");
        print "#-----------------------------------";
        print "#📂 Duplicado exacto: " parts[1] " (" parts[2] " bytes)";
        # Fichero más superficial (menor profundidad)
        min_d=1e9; idx=0;
        for (i=1; i<=n; i++) {
          d = depth(arr[i]);
          depths[i]=d;
          if (d < min_d) { min_d=d; idx=i; }
        }
        for (i=1; i<=n; i++) {
          mark = (i==idx) ? "#" : " ";
          printf "%s%s [nivel=%d]\n", mark, arr[i], depths[i];
        }
      }
    }
    if (dup_groups==0)
      print "ℹ️ No se encontraron duplicados exactos (nombre + tamaño)" > "/dev/stderr";
    else
      print "✅ Se encontraron " dup_groups " grupos de duplicados." > "/dev/stderr";
  }
' > "$OUT"

echo
if [[ -s "$OUT" ]]; then
  echo "✅ Informe generado: $(realpath "$OUT")"
else
  echo "ℹ️ El fichero está vacío: no se detectaron duplicados exactos tras exclusiones."
fi
echo


2. Copia de carpetas en base al fichero de duplicados:

  • Las líneas 55 a 59 indican las carpetas a excluir
  • Las lÍneas 62 a 64 indica las extensiones de ficheros a excluir
  • Al final nos da el nombre del fichero generado con la lista de ficheros copiados
  • Para ejecutar el script hay que pasarle las carpeta de origen y destino y la ruta del fichero de duplicados generados en el punto anterior
Veamos el script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#!/bin/bash
# ============================================================
# 🧰 copiar_no_duplicados.sh
# Autor: Ximo Dante + IA
# Copia solo los ficheros no duplicados ni excluidos
# según el informe generado por buscar_duplicados_exacto_final.sh
# Sin usar rsync — usa find + cp --parents (más estable)
# ============================================================

set -euo pipefail
IFS=$'\n'

ORIGEN="${1:-}"
DESTINO="${2:-}"
DUP_FILE="${3:-}"

if [[ -z "$ORIGEN" || -z "$DESTINO" || -z "$DUP_FILE" ]]; then
  echo "❌ Uso: $0 /carpeta/origen /carpeta/destino duplicados_final_xxx.txt"
  exit 1
fi

if [[ ! -d "$ORIGEN" ]]; then
  echo "❌ Carpeta origen no existe: $ORIGEN"
  exit 1
fi
if [[ ! -f "$DUP_FILE" ]]; then
  echo "❌ No existe el fichero de duplicados: $DUP_FILE"
  exit 1
fi

mkdir -p "$DESTINO"
TS=$(date +%Y%m%d_%H%M%S)
LOG="$DESTINO/copiar_no_duplicados_${TS}.log"

echo "📂 Carpeta origen: $ORIGEN"
echo "📁 Carpeta destino: $DESTINO"
echo "🧾 Archivo de duplicados: $DUP_FILE"
echo "📝 Log: $LOG"
echo

# ------------------------------------------------------------
# 1️⃣ Construir lista de rutas a excluir
# ------------------------------------------------------------
EXCL_TMP=$(mktemp)
grep -v '^#' "$DUP_FILE" | grep -E '^/' > "$EXCL_TMP" || true

# ------------------------------------------------------------
# 2️⃣ Encontrar archivos válidos (no duplicados ni excluidos)
# ------------------------------------------------------------
echo "🔎 Escaneando archivos válidos..."

find "$ORIGEN" \
  \( -type d \( \
      -name ".*" -o -name "\$*" \
      -o -iname "Archivos de programa" -o -iname "Descargas" -o -iname "Downloads" \
      -o -iname "Documents and Settings" -o -iname "Logs" -o -iname "PerfLogs" \
      -o -iname "Program Files" -o -iname "Program Files (x86)" -o -iname "ProgramData" \
      -o -iname "Recovery" -o -iname "System Volume Information" -o -iname "Temp" \
      -o -iname "Users" -o -iname "Windows" -o -iname "Windows10Upgrade" \
    \) -prune \) \
  -o -type f \
     ! -iname "*.exe" ! -iname "*.log" ! -iname "*.dll" ! -iname "*.sys" \
     ! -iname "*.part" ! -iname "*.crdownload" ! -iname "*.tmp" \
     ! -iname "*.partial" ! -iname "*.download" ! -iname "*!qB*" ! -iname "*.aria2" \
  -print0 2>/dev/null \
| grep -zavFf "$EXCL_TMP" \
| tee >(xargs -0 -I{} bash -c '
      FILE="{}"
      REL="${FILE#'"$ORIGEN"'/}"
      DEST_FILE="'"$DESTINO"'/$REL"
      mkdir -p "$(dirname "$DEST_FILE")"
      cp -p "$FILE" "$DEST_FILE"
      echo "✔ Copiado: $REL" >> "'"$LOG"'"
  ') > /dev/null

# ------------------------------------------------------------
# 3️⃣ Mostrar resumen
# ------------------------------------------------------------
COPIADOS=$(grep -c "✔ Copiado:" "$LOG" || echo 0)
echo
echo "✅ Copia completada."
echo "📦 Archivos copiados: $COPIADOS"
echo "📄 Log detallado: $LOG"
echo




sábado, 11 de octubre de 2025

WEBPROPv2 (XII). Mostrar ficheros en un control FastHTML

 Veamos el código que ejecuta en python  un uvicorn y muestra el fichero que le decimos:

El truco está en utilizar libreoffice para transformar los ficheros de ofimática ( ods,odt, xls,xlsx, doc,docx ..) a html y mostrar el html generado.

Si el fichero es grande puede enlentecer mucho la carga, conversión y muestra

import base64
import mimetypes
from fasthtml import common as fh
import uvicorn
from pathlib import Path
import subprocess
import shutil


app = fh.FastHTML()



def convertir_a_html(path: Path) -> Path | None:
    """Convierte el archivo a HTML usando LibreOffice y devuelve la ruta resultante."""
    output_dir = Path("/tmp")
    result = subprocess.run(
        ["libreoffice", "--headless", "--convert-to", "html", str(path), "--outdir", str(output_dir)],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE
    )
    if result.returncode == 0:
        html_file = output_dir / (path.stem + ".html")
        if html_file.exists():
            return html_file
    return None

def generar_viewer(path: Path):
    mime_type, _ = mimetypes.guess_type(str(path))
    if not mime_type:
        mime_type = "application/octet-stream"

    data = path.read_bytes()
    b64 = base64.b64encode(data).decode("utf-8")

    # Imágenes
    if mime_type.startswith("image/"):
        return fh.Img(src=f"data:{mime_type};base64,{b64}", cls="img-fluid rounded shadow", alt=path.name)

    # PDF
    if mime_type == "application/pdf":
        return fh.Iframe(src=f"data:{mime_type};base64,{b64}",
                         style="width:100%; height:90vh;",
                         cls="border rounded shadow-sm",
                         title=path.name)

    # Texto
    if mime_type.startswith("text/"):
        content = data.decode("utf-8", errors="ignore")
        return fh.Pre(content, cls="bg-light p-3 border rounded")

    # ODS / XLSX / DOCX — convertir a HTML
    convertible_types = {
        "application/vnd.oasis.opendocument.spreadsheet",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        "application/vnd.oasis.opendocument.text",
        'application/msword',
        'application/vnd.ms-excel',
    }
    if mime_type in convertible_types:
        html_file = convertir_a_html(path)
        if html_file:
            html_content = html_file.read_text(encoding="utf-8", errors="ignore")
            return fh.Div(fh.NotStr(html_content), cls="border p-2 bg-white shadow-sm")

    # Otros: ofrecer descarga
    return fh.Div(
        fh.P("El archivo no puede mostrarse directamente en el navegador.", cls="text-muted"),
        fh.A("Descargar archivo", href=f"data:{mime_type};base64,{b64}",
             download=path.name, cls="btn btn-primary mt-3"),
        cls="text-center"
    )

@app.get("/mostrarfichero")
async def mostrar_fichero(req):
    path_param = req.query_params.get("pathfichero")
    if not path_param:
        return fh.Html(fh.Body(fh.H4("⚠️ Falta el parámetro 'pathfichero'")))
    path = Path(path_param)
    if not path.exists():
        return fh.Html(fh.Body(fh.H4(f"❌ Archivo no encontrado: {path_param}")))
    viewer = generar_viewer(path)
    return fh.Html(
        fh.Head(fh.Link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css")),
        fh.Body(
            fh.Div(
                fh.H3(f"Mostrando: {path.name}", cls="text-center my-4"),
                viewer,
                cls="container"
            )
        )
    )

if __name__ == "__main__":
    print("🚀 Abre en el navegador: http://localhost:8000/mostrarfichero?pathfichero=/home/edu/kk/ODS01.ods")
    uvicorn.run(app, host="0.0.0.0", port=8000)


Y para ejecutarlo, tal como se dice en as últimas sentencias, ejecutamos el programa python desde visual studio code y en un navegador apuntamos a esa ruta donde en /home/kk/OSD01.ods está un fichero de hoja de cálculo

http://localhost:8000/mostrarfichero?pathfichero=/home/edu/kk/ODS01.ods