Almacenamiento de evidencia
Bucket privado, URLs firmadas, compresión y retención de las fotografías.
La evidencia fotográfica vive en Cloudflare R2, un almacenamiento de objetos compatible con el protocolo S3.
PostgreSQL guarda únicamente metadata: la clave del objeto, el tipo MIME, el tamaño, las dimensiones, a qué tarea pertenece y sus marcas de tiempo. Ninguna imagen se guarda en la base de datos ni en el disco del contenedor.
Por qué R2
- No cobra por transferencia de salida, que es justo lo que más pesaría al mostrar galerías de fotografías.
- Habla el protocolo S3, así que el mismo código funciona contra MinIO en desarrollo.
- Guardar imágenes en PostgreSQL inflaría los respaldos hasta hacerlos inmanejables; guardarlas en el disco del contenedor las perdería en cada redespliegue.
El bucket es privado
No existe ninguna URL pública permanente. Cada visualización pasa por el servidor, que comprueba permisos y sólo entonces firma una URL de corta duración.
| Operación | Vigencia de la firma | Por qué |
|---|---|---|
| Lectura | 10 minutos | De sobra para cargar una galería; una URL copiada por accidente en un chat deja de funcionar enseguida |
| Escritura | 15 minutos | Más generosa: el personal sube desde redes móviles lentas |
Flujo de subida
sequenceDiagram
participant T as Teléfono
participant A as Aplicación
participant R as R2
T->>T: 1. Comprimir, orientar, borrar EXIF
T->>A: 2. requestUpload(servicio, tarea, tipo, tamaño)
A->>A: 3. Autorizar, validar, generar la clave
A->>A: 4. Crear Evidence en estado PENDING
A->>R: 5. Firmar URL de subida
A-->>T: 6. URL firmada + id de evidencia
T->>R: 7. PUT directo del archivo
T->>A: 8. confirmUpload(id)
A->>R: 9. HEAD: ¿existe de verdad?
A->>A: 10. Marcar CONFIRMEDEl archivo no atraviesa la aplicación: va directo del teléfono a R2. Un servidor pequeño no se convierte en cuello de botella aunque suban veinte fotos a la vez.
Estados de la evidencia
| Estado | Significa |
|---|---|
PENDING | Se firmó la subida, aún sin confirmar |
CONFIRMED | El objeto existe en R2 y es evidencia válida |
DELETED | Borrado lógico por una persona; el objeto se eliminó de R2 |
EXPIRED | Superó la retención; el objeto se eliminó, la metadata permanece |
Sólo CONFIRMED cuenta para completar una tarea. Si la subida falla a
medias, la tarea sigue incompleta, que es lo correcto: nunca se marca algo como
hecho antes de que el servidor lo confirme.
Compresión en el navegador
Una foto de móvil pesa entre 4 y 12 MB. Antes de subirla, el navegador:
- La decodifica aplicando la orientación EXIF, para que no salga girada.
- La redimensiona a 1600 px en el lado mayor.
- La vuelve a codificar en WebP con calidad 0,82.
- Si aún supera el límite, repite con calidad 0,65.
Resultado típico: 200–400 KB.
Volver a codificar en un canvas descarta todos los metadatos EXIF,
incluidas las coordenadas GPS y el modelo del dispositivo. Royal Clean no
rastrea a su personal, así que esos datos no deben ni salir del teléfono.
Implementado en src/lib/image-compression.ts.
Claves de objeto
Las genera siempre el servidor:
royal-clean/services/{serviceId}/tasks/{taskId}/{uuid}.webp
royal-clean/services/{serviceId}/incidents/{incidentId}/{uuid}.webp
royal-clean/services/{serviceId}/general/{uuid}.webpEl nombre del archivo del usuario nunca se usa como ruta: evita el path traversal, las colisiones y que datos personales acaben en el nombre del objeto. El UUID hace la clave impredecible.
Validaciones
| Qué | Límite |
|---|---|
| Entrada de imagen | 15 MB antes de comprimir |
| Subida de imagen | 3 MB (objetivo real: menos de 1 MB) |
| Subida de vídeo | 60 MB, sin transcodificar |
| Tipos aceptados | image/webp, image/jpeg, video/mp4, video/quicktime, video/webm |
El tipo y el tamaño van firmados en la URL de subida: cambiar cualquiera de
los dos invalida la firma. Hay un test que lo comprueba subiendo con un
Content-Type distinto del autorizado.
Los límites viven en
src/modules/evidence/constants.ts,
definidos una sola vez para el compresor del cliente, la validación del servidor
y los mensajes de la interfaz.
Visualización
GET /api/evidence/{id}- Busca la evidencia y su servicio.
- Llama a
requireServiceAccess(). Si el usuario no tiene derecho, 404. - Firma una URL de 10 minutos.
- Responde 302 hacia esa URL.
La redirección permite escribir <img src="/api/evidence/abc"> sin JavaScript,
y hace que la URL firmada no quede en el marcado que el navegador guarda en el
historial. La respuesta lleva Cache-Control: private, no-store.
Una evidencia borrada o expirada devuelve 410, para distinguirla de un id inválido.
Retención
Por defecto 12 meses, configurable. expiresAt se calcula al crear la
evidencia.
El trabajo de mantenimiento:
- Busca evidencia
CONFIRMEDconexpiresAten el pasado. - Borra los objetos de R2 por lotes.
- Marca como
EXPIREDsólo las que se borraron de verdad; las que fallaron se reintentan en la siguiente ejecución.
El historial del servicio se conserva íntegro. Fechas, tareas, horas, incidentes y cifras siguen ahí; sólo desaparecen las fotografías.
Limpieza de subidas abandonadas
Una subida que se firma y nunca se confirma dejaría metadata huérfana y, posiblemente, un objeto pagando almacenamiento para siempre.
Pasados 60 minutos sin confirmar, el trabajo de mantenimiento borra el objeto —por si llegó a subirse— y elimina la fila.
Desarrollo local
docker compose levanta MinIO, que habla el mismo protocolo S3. El
.env.example ya apunta ahí:
R2_ACCOUNT_ID="local"
R2_ACCESS_KEY_ID="royalclean"
R2_SECRET_ACCESS_KEY="royalclean_dev_secret"
R2_BUCKET_NAME="royal-clean-evidence"
R2_ENDPOINT="http://localhost:9000"Consola web en http://localhost:9001.
Esto permite probar el flujo completo —firmar, subir, confirmar, expirar— sin una cuenta de Cloudflare, y es lo que ejecutan los tests de integración.
Direccionamiento por ruta
El cliente usa forcePathStyle: true. El estilo virtual por defecto del SDK
construiría hosts como royal-clean-evidence.localhost, que no resuelve, y
depende de comodines DNS que no controlamos en el endpoint de R2. Con
direccionamiento por ruta, el mismo código funciona en ambos entornos.
Este defecto lo destapó un test de integración, no una revisión: ver DECISIONS.md DEC-018.
Configurar R2 en producción
- Cloudflare → R2 → Create bucket →
royal-clean-evidence. - Acceso público desactivado.
- Manage R2 API Tokens → Create API Token:
- Permisos: Object Read & Write
- Alcance: sólo ese bucket
- Guarda el Access Key ID y el Secret: el secreto no se vuelve a mostrar.
- Copia tu Account ID.
Variables en Dokploy: ver DOKPLOY.md.
Coste estimado
Con unos 20 servicios semanales, 5 fotos por servicio y 300 KB por foto:
- ~30 GB al año antes de la retención
- Con retención de 12 meses, el volumen se estabiliza
- R2 no cobra salida, así que el coste es esencialmente el almacenamiento
Muy por debajo de lo que costaría el mismo tráfico en un proveedor que sí cobre transferencia.