Skip to content

Repository files navigation

Vistazo

Grabador de pantalla en el navegador para soporte técnico. Le pasas una URL a tu cliente, entra, pulsa un botón, graba lo que le pasa y te devuelve un enlace que te pega en el email. Sin extensiones, sin instalar nada, sin registrarse.

Pensado para agencias y equipos de soporte que hoy le dicen al cliente «grábamelo con Loom» y se topan con la extensión, la cuenta y los límites del plan gratuito.

Auto-alojado: los vídeos van a tu MinIO o S3 y se borran solos a los 60 días.

Cliente → captura.hormi.link → [Grabar] → sube a tu MinIO → enlace → tu email

Qué hace

  • Graba la pantalla desde el navegador, con getDisplayMedia y MediaRecorder. Sin extensión ni instalación: son APIs estándar de Chrome, Edge, Firefox y Safari.
  • Micrófono incluido, mezclado con el audio de la web en una sola pista, para que el cliente pueda explicar el problema mientras lo enseña.
  • Sube mientras graba. Al pulsar «Parar» casi todo está ya en el servidor, así que el enlace aparece en segundos en vez de tras una espera larga. Si se corta la conexión a mitad, cada trozo se reintenta con espera creciente. (En Safari no: ver «Formato» más abajo.)
  • Avisa si no se está capturando nada, en vez de dejar al cliente grabar diez minutos de vídeo vacío y descubrirlo al final.
  • Revisar antes de enviar. El cliente ve su grabación y decide: enviar o descartar. Al descartar, la subida se aborta y no queda nada en el almacén.
  • Ruta alternativa en móvil. iOS y Android no dejan que una web grabe la pantalla; la app lo detecta y muestra las instrucciones del propio dispositivo y un formulario de subida.
  • Retención de 60 días aplicada por el propio MinIO, y avisada en pantalla.
  • Personalizable: nombre, logo (con variante para modo oscuro), color, email de soporte y un aviso libre, todo por variables de entorno.

Qué NO hace (a propósito)

  • No crea tickets ni se integra con ningún helpdesk. El resultado es un enlace, y eso cubre también a quien todavía no es cliente y sólo quiere pedir presupuesto.
  • No hay cuentas, ni login, ni base de datos. Los metadatos viven en un meta.json junto al vídeo, en el mismo bucket.
  • No edita, ni recorta, ni transcribe.

Límites reales

Conviene conocerlos antes de desplegar; ninguno es un fallo, son restricciones de los navegadores:

Situación Qué pasa
iPhone / iPad getDisplayMedia no existe en iOS, ni en Chrome para iOS (que es Safari por dentro). La app detecta y ofrece subir un vídeo grabado con la función nativa.
Android Soporte irregular. Mismo camino alternativo.
Audio del sistema en Mac Sólo se captura al compartir una pestaña de Chrome, no la pantalla completa. Las instrucciones lo dicen explícitamente.
Safari en escritorio Graba, pero no deja elegir una pestaña concreta: sólo ventana o pantalla. Genera MP4.
Firefox Graba en WebM. No admite capturar el audio del sistema; el micrófono sí.
Formato Se prefiere WebM, y no por calidad: en Chrome, MediaRecorder con MP4 ignora el timeslice y no entrega un solo byte hasta que se llama a stop() (medido: 12 s de grabación, primer y único trozo a los 12.295 ms). Con MP4 no hay subida progresiva. MP4 queda como último recurso para Safari, que no sabe grabar WebM; allí todo se sube al parar.
Reproducir en Safari Un WebM grabado en Chrome no se reproduce en Safari. La página del enlace lo detecta y ofrece descargar el archivo, que está intacto.
HTTPS obligatorio Sin conexión segura el navegador no da acceso a la pantalla. En localhost sí funciona.

Instalación en Easypanel

1. Preparar MinIO

Necesitas un bucket, la carpeta de las grabaciones, la regla de retención y un usuario con permiso sólo sobre esa carpeta.

¿La consola web de MinIO no carga? Es lo habitual detrás de un proxy. Sigue docs/minio-por-ssh.md: la guía completa paso a paso desde el servidor, con comprobación de que el permiso queda bien acotado.

Si tienes acceso a la máquina donde está el repo, el script lo hace todo de una vez:

MINIO_URL=https://minio.tu-servidor.com \
MINIO_ROOT_USER=admin \
MINIO_ROOT_PASSWORD=tu-clave \
./scripts/preparar-minio.sh

Al terminar imprime las variables de entorno listas para pegar. Guarda S3_SECRET_KEY en ese momento: no se puede volver a consultar.

Hacerlo a mano, desde la consola de MinIO

Bucket: créalo con el nombre vistazo y déjalo privado. Los vídeos nunca se sirven directamente desde MinIO: pasan por la app, para poder caducarlos y cambiar de almacén sin romper los enlaces ya enviados.

Regla de ciclo de vida (Buckets → vistazo → Lifecycle → Add Rule), o por API:

{
  "Rules": [
    {
      "ID": "borrar-grabaciones-60-dias",
      "Status": "Enabled",
      "Filter": { "Prefix": "grabaciones/" },
      "Expiration": { "Days": 60 },
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
    }
  ]
}

Expiration.Days cuenta desde la fecha de creación del objeto, que es justo lo que queremos. AbortIncompleteMultipartUpload limpia las subidas que se quedaron a medias y que, si no, ocupan espacio invisible.

Usuario y política (Identity → Users → Create). Política mínima:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject", "s3:GetObject", "s3:DeleteObject",
        "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"
      ],
      "Resource": ["arn:aws:s3:::vistazo/grabaciones/*"]
    }
  ]
}

La retención se configura en dos sitios y deben coincidir. MinIO es quien borra de verdad; RETENTION_DAYS sólo controla la fecha que se le enseña al cliente. Si cambias una, cambia la otra.

2. Crear el servicio en Easypanel

  1. Project → + Service → App. Nómbralo vistazo.
  2. Source: GitHub → repositorio dhaula/vistazo, rama main.
  3. Build: Dockerfile (Easypanel detecta el Dockerfile de la raíz).
  4. Environment: pega el contenido de .env.example con tus valores. Como mínimo: PUBLIC_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.
  5. Deploy.

3. Dominio y puerto

En Domains → Add Domain:

  • Host: captura.hormi.link
  • Puerto destino / Proxy Port: el mismo valor que hayas puesto en PORT (en .env.example viene 5200)
  • HTTPS: activado (Let's Encrypt)

⚠️ El puerto destino es el error más habitual. Easypanel propone 80 por defecto. Si lo dejas así, el contenedor arranca bien pero el dominio responde 502.

Sobre los puertos y otras apps: cada servicio de Easypanel corre en su propio contenedor, con su propia red. Que otra app use el 3000 no impide que Vistazo también lo use: no hay conflicto salvo que publiques puertos del anfitrión a mano. Aun así, .env.example trae PORT=5200 para que cada app tenga un número distinto y sea más fácil de seguir de un vistazo.

Apunta el DNS de tu dominio al servidor antes de emitir el certificado.

4. Comprobar

curl https://captura.hormi.link/healthz

Debe responder {"ok":true,"bucket":"vistazo",...}. Después entra con el navegador y haz una grabación de diez segundos de prueba.


Variables de entorno

Variable Por defecto Para qué
PUBLIC_URL (se deduce de las cabeceras) URL con la que se construyen los enlaces. Sin barra final.
PORT 3000 Puerto de escucha. Debe coincidir con el puerto destino del dominio.
S3_ENDPOINT obligatoria URL de MinIO o S3.
S3_BUCKET obligatoria Nombre del bucket.
S3_ACCESS_KEY / S3_SECRET_KEY obligatorias Credenciales.
S3_PREFIX grabaciones/ Carpeta. Debe coincidir con la regla de retención.
S3_FORCE_PATH_STYLE true Déjalo en true para MinIO.
S3_REGION us-east-1 MinIO la ignora.
RETENTION_DAYS 60 Sólo informativo: quien borra es MinIO.
MAX_DURATION_MIN 15 La grabación se para sola al llegar.
MAX_SIZE_MB 600 Ídem, por tamaño.
VIDEO_KBPS / AUDIO_KBPS 2500 / 128 Calidad. Subir sube el peso.
FRAME_RATE 15 Suficiente para enseñar una web.
PART_SIZE_MB 8 Tamaño de cada trozo. Mínimo 5 (lo exige S3).
UPLOADS_PER_HOUR_PER_IP 20 Freno contra abuso casual.
UPLOADS_PER_HOUR_TOTAL 60 Tope global de grabaciones nuevas por hora, sumando todas las IP. Acota cuánto disco puede llenarse.
ORG_NAME Soporte Se muestra en la cabecera.
BRAND_LOGO_URL (vacío) Ruta o URL del logo. Si está vacío, se muestra ORG_NAME en texto.
BRAND_LOGO_URL_DARK (vacío) Variante para modo oscuro, si el logo principal no se ve sobre fondo oscuro.
BRAND_COLOR #198994 Color de rellenos y botones.
BRAND_COLOR_TEXT #0E6C75 Variante para texto (necesita más contraste).
SUPPORT_EMAIL (vacío) Si lo pones, aparece un botón «Abrir mi email con el enlace».
CUSTOM_NOTICE (vacío) Frase libre en el pie.
ORG_URL (vacío) Enlace del crédito: «Hecho con Vistazo de Tu Nombre».
FOOTER_LINKS (vacío) Menú del pie. Ver abajo.
LOG_LEVEL info warn en producción si no quieres ruido.

El menú del pie

FOOTER_LINKS define los enlaces que aparecen arriba del pie, en las dos páginas (la de grabar y la del enlace). Entradas separadas por ;, y dentro Texto|URL:

FOOTER_LINKS=Política de privacidad|https://ejemplo.com/privacidad ; Aviso legal|https://ejemplo.com/aviso-legal ; Contacto|mailto:hola@ejemplo.com

Se aceptan http, https, mailto y rutas que empiecen por /. Cualquier otro esquema —javascript:, data:— se descarta al arrancar. Máximo 8 entradas.

Los enlaces se construyen en el navegador con textContent, nunca con innerHTML.

Sobre cookies y privacidad

Vistazo no usa cookies, ni almacenamiento local, ni analítica, ni ningún recurso de terceros; la política de seguridad de contenido bloquea las peticiones externas. No hay nada que consentir, así que no lleva banner de cookies. Aun así, se graba la pantalla de una persona, así que conviene enlazar tu política de privacidad con FOOTER_LINKS y explicar allí qué hacéis con las grabaciones y cuánto duran.


Desarrollo

npm install
cp .env.example .env    # y rellena los valores
npm run dev

Con Docker y un MinIO de pruebas incluido:

docker compose up --build

Levanta Vistazo en localhost:3000, MinIO en localhost:9000 y su consola en localhost:9001 (minioadmin / minioadmin), con el bucket y la retención ya configurados.

Pruebas

npm test

Levanta un S3 falso en memoria y recorre el ciclo real: abrir grabación, subir por trozos, cerrar, leer la ficha y reproducir entera y por rangos. También comprueba que el token protege la subida, que descartar aborta el multipart y que se respetan los límites de tamaño y de formato.


Cómo funciona por dentro

Navegador                        Servidor                     MinIO
─────────                        ────────                     ─────
getDisplayMedia + getUserMedia
   │
   ├─ mezcla audio (AudioContext)
   ├─ MediaRecorder, trozos de 2 s
   │
   ├─ POST /api/uploads ──────────► CreateMultipartUpload ────► uploadId
   │                          ◄──── { id, token }
   │
   ├─ acumula hasta 8 MiB
   ├─ PUT .../parts/1 ────────────► UploadPart ───────────────► ETag
   ├─ PUT .../parts/2 ────────────► UploadPart ───────────────► ETag
   │      (mientras sigue grabando)
   │
   ├─ [Parar] → vista previa local
   ├─ [Enviar]
   ├─ POST .../complete ──────────► CompleteMultipartUpload
   │                                PutObject meta.json
   │                          ◄──── { url: /v/<id> }
   └─ enlace para el email

Detalles que importan:

  • El bucket es privado. /api/v/:id/stream hace de intermediario y respeta las peticiones Range, que es lo que usa el <video> para saltar por la barra de tiempo.
  • Los identificadores son de 16 caracteres aleatorios de un alfabeto sin caracteres ambiguos. No se pueden adivinar y se pueden dictar por teléfono.
  • El estado de las subidas en curso vive en memoria, a propósito: una grabación a medias no vale nada si el contenedor se reinicia, y así no hace falta base de datos. Un barrendero interno aborta las abandonadas a las 6 horas.
  • Sin enlaces firmados. Una URL prefirmada de S3 dura como mucho 7 días, así que no sirve para una retención de 60. Por eso el vídeo se sirve a través de la app.
  • La duración del WebM se recalcula en el reproductor. El WebM de MediaRecorder no lleva la duración en el contenedor y el navegador la da como infinita, lo que deja la barra de tiempo inservible. La página del enlace fuerza un salto muy lejano para que el navegador la calcule y vuelve al principio. Es un apaño conocido, pero evita tener que reprocesar cada vídeo con ffmpeg en el servidor.

Escalado

Tal cual está, una sola réplica. El registro de subidas en curso es un Map en memoria: con dos réplicas detrás de un balanceador, los trozos de una misma grabación irían a instancias distintas y el uploadId no se encontraría. Para escalar horizontalmente habría que mover ese registro a Redis, o activar sesiones pegajosas en el proxy. Para el uso al que está pensado (unas cuantas grabaciones al día), una réplica sobra.


Privacidad y seguridad

Estás grabando la pantalla de otra persona, y ahí puede aparecer información suya o de terceros.

No se pueden listar las grabaciones. No existe ningún endpoint que devuelva un listado: para ver una hay que conocer su enlace. El usuario de MinIO que usa la app tampoco tiene permiso s3:ListBucket, así que ni robando sus credenciales se podría enumerar el bucket. Los identificadores son 16 caracteres de un alfabeto de 56, unos 93 bits: no se adivinan por fuerza bruta.

No se indexa nada. Las páginas llevan <meta name="robots" content="noindex">, hay un robots.txt que prohíbe todo el sitio, y todas las respuestas —incluido el propio vídeo— llevan la cabecera X-Robots-Tag: noindex, nofollow, noarchive. Esto último es lo que importa: un archivo de vídeo no puede llevar etiquetas HTML, y el enlace puede acabar pegado en cualquier sitio público.

El resto:

  • Los vídeos se borran solos a los 60 días, avisado en pantalla y en la página del enlace.
  • El bucket es privado; el vídeo sólo se sirve a través de la app.
  • El token que autoriza subir trozos se compara en tiempo constante.
  • Referrer-Policy: no-referrer, para que el identificador no viaje en la cabecera Referer si el vídeo enlaza a algún sitio.
  • frame-ancestors 'none': la página no se puede incrustar en otra.
  • /healthz sólo dice {"ok":true}; no publica el bucket ni el prefijo.
  • Se guarda el User-Agent en el meta.json para depurar, y no se expone en la API.
  • Sin cookies, sin analítica, sin recursos de terceros.

Todo lo anterior está cubierto por pruebas automáticas (npm test).

Lo que no está resuelto

La URL es pública y cualquiera puede subir. Los topes por IP y global acotan el peor caso, pero alguien decidido puede llenar el disco: con los valores por defecto, hasta 60 grabaciones por hora. Si eso preocupa, baja UPLOADS_PER_HOUR_TOTAL y MAX_SIZE_MB, y vigila el espacio del bucket. Un código de acceso en la URL sería la solución completa, pero cerraría la puerta a quien todavía no es cliente y sólo quiere enseñar un problema para pedir presupuesto.


Licencia

MIT. Ver LICENSE.

About

Grabador de pantalla en el navegador para soporte técnico. El cliente entra a una URL, graba sin instalar nada y te devuelve un enlace. Auto-alojado sobre MinIO/S3, con retención automática. Alternativa a Loom sin extensiones.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages