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.
El paso 7 es una petición del navegador a otro origen, así que depende de que el bucket tenga política CORS. Si falta, el navegador ni siquiera llega a enviar el archivo y la subida falla entera. Ver CORS del bucket, porque es un fallo que no se parece a lo que es.
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.
- Configura CORS en el bucket. Sin esto la evidencia no se puede subir. Ver la sección siguiente.
Variables en Dokploy: ver DOKPLOY.md.
CORS del bucket
El bucket necesita una política CORS obligatoriamente. El navegador sube el
archivo directo a R2 (paso 7 del flujo), y sin política el preflight OPTIONS
se rechaza antes de enviar nada.
En Cloudflare: R2 → royal-clean-evidence → Settings → CORS Policy.
[
{
"AllowedOrigins": ["https://crm.royalcleancr.com"],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": ["content-type", "content-length"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]AllowedOrigins lleva el dominio EXACTO desde el que se sirve la aplicación. Un
dominio nuevo —o uno de pruebas— necesita añadirse aquí o sus subidas fallarán.
content-type y content-length no son decorativos: createUploadUrl los
incluye en la firma para que el cliente no pueda subir un tipo o un tamaño
distintos de los autorizados. Si no están permitidos, el preflight falla.
Cómo se manifiesta cuando falta
Es un fallo engañoso, y conviene reconocerlo:
- El personal ve «No fue posible subir la fotografía. Revisa tu conexión e inténtalo nuevamente», que suena a problema de red y no lo es.
- Falla siempre, en todos los dispositivos y todas las fotos. Un fallo de red sería intermitente.
- No aparece nada en los registros del servidor: la petición nunca llega a la aplicación, porque muere en el navegador.
- Como las tareas exigen fotografía, se cae el flujo entero de completar servicios.
Para comprobarlo desde cualquier máquina, sin credenciales:
curl -i -X OPTIONS "https://<account-id>.r2.cloudflarestorage.com/royal-clean-evidence/prueba" -H "Origin: https://crm.royalcleancr.com" -H "Access-Control-Request-Method: PUT"Con la política puesta responde 204 con las cabeceras Access-Control-Allow-*.
Sin ella, 403 con CORS not configured for this bucket.
El token de API de la aplicación es de Object Read & Write y no puede
cambiar la configuración del bucket: intentar PutBucketCors con él devuelve
AccessDenied. La política se pone desde el panel de Cloudflare o con un token
con permiso de administración.
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.