Protección de contenido (marca de agua anti-captura) — Referencia técnica
← Volver a Protección de contenido
DynamicWatermark en sí sigue siendo un componente puramente del lado del cliente, de capa de presentación (Fase D.3 / roadmap 4.11) — no hay ningún cambio de backend ni de GraphQL detrás. Superpone una marca de agua forense sobre el contenido protegido: el @usuario de quien está viendo actualmente más una marca de tiempo en vivo, en mosaico sobre el contenido para que sobreviva al recorte y no se pueda quitar limpiamente de una captura. Corregido en esta sesión: su integración había sido retirada de la vista de post exclusivo y de la burbuja de chat de mensaje de pago, quedando montada solo en transmisiones en vivo no públicas; ahora vuelve a estar conectada también en el contenido multimedia desbloqueado de posts exclusivos y de mensajes de pago (ver la tabla de integración a continuación).
Junto a ella ahora existe, por separado, una marca de agua del lado del servidor / incrustada, opcional (más abajo) — graba una marca estática "@usuario-del-creador" directamente en los píxeles de la imagen real al momento de subirla, en lugar de superponer la identidad del visitante actual al momento de renderizar. Las dos son independientes y resuelven problemas distintos (ver "Incrustada vs. dinámica" más abajo).
Marca de agua del lado del servidor / incrustada (opcional, desactivada por defecto)
services/post-media-processing.service.js, conectado en post.manager.js#createPost(). Para una publicación con coinPrice > 0 (la misma condición que dispara la vista previa borrosa, ver exclusive-posts.md), cada elemento de imagen recibe un SVG en mosaico con "@usuario" compuesto directamente sobre sus píxeles vía sharp().composite(); el archivo resultante reemplaza PostMedia.mediaUrl (el original previo a la marca de agua queda en el almacenamiento, sin referenciar — ver "Simplificación conocida" más abajo). Se aplica al medio REAL (tanto elementos isPreview como bloqueados) — esto es lo que ve un visitante que ya pagó, así que protege contra que capture y redistribuya el archivo crudo, algo que la superposición del lado del cliente DynamicWatermark de arriba no puede hacer (nunca toca el archivo subyacente).
Interruptor: variable de entorno BAKE_IN_WATERMARK, desactivada por defecto — deliberadamente conservador (a diferencia de la vista previa borrosa, esto cambia lo que se sirve) hasta que producto/diseño apruebe marcar con agua el medio real de cada publicación de pago. El video se omite (mismo motivo que el vacío de video en la vista previa borrosa).
Incrustada vs. dinámica — cuál se ejecuta cuándo
DynamicWatermark (existente) | Marca de agua incrustada (nueva) | |
|---|---|---|
| Dónde vive | Componente React del lado del cliente | Del lado del servidor, al momento de subir (sharp) |
| Qué muestra | @usuario de quien está viendo en ese momento + marca de tiempo en vivo | El @usuario del creador (estático) |
| ¿Toca el archivo? | No — solo superposición, el archivo crudo no lleva marca de agua | Sí — grabada en los píxeles, reemplaza mediaUrl |
| Integración actual | Transmisiones en vivo no públicas, posts exclusivos desbloqueados, mensajes de pago desbloqueados (tabla más abajo) | Imágenes de posts de pago, opcional vía BAKE_IN_WATERMARK |
| Propósito | Forense — rastrea una filtración hasta quien la capturó | Preventiva (en parte) — cada copia del archivo lleva la marca del creador, incluso fuera de la plataforma |
Simplificación conocida
El archivo original subido antes de la marca de agua no se elimina del almacenamiento después de subir el reemplazo con marca de agua — simplemente queda sin referenciar (ninguna fila PostMedia apunta ya a él). Eliminarlo quedó fuera de esta etapa para evitar una condición de carrera con cualquier otro proceso que todavía pudiera leer la clave original a mitad de una solicitud; un proceso de limpieza posterior podría eliminarlo una vez que nada lo referencie.
Dónde vive esto
apps/frontend-nextjs/src/components/common/DynamicWatermark.tsx— el componente de superposición reutilizable. Lee al usuario autenticado desdeAuthContext(o acepta unlabelque lo sobrescriba), renderiza una cuadrícula rotada y en mosaico de@usuario • YYYY-MM-DD HH:MMcon baja opacidad,pointer-events-nonepara que nunca intercepte clics / controles de video / navegación del carrusel. La marca de tiempo se actualiza cada 30s víasetInterval, así que una captura siempre codifica aproximadamente cuándo se tomó, no solo cuándo se cargó la página. Colócalo dentro de cualquier contenedor multimedia conposition: relative.
Puntos de integración
| Superficie | Archivo | Condición |
|---|---|---|
| Video de transmisión en vivo no pública | LiveRoomPage.tsx | !isOwner && connected && liveStream.visibility !== 'public' — el transmisor no lleva marca de agua, solo los espectadores de una transmisión privada / solo seguidores / amigos cercanos |
| Multimedia de post exclusivo desbloqueado (cuadrícula de feed/perfil) | PostCard.tsx | !!displayPost.isPaid && !!displayPost.hasPostAccess && authUser?.id !== displayPost.userId — los posts exclusivos se muestran en línea dentro de la cuadrícula normal de "posts" (post.isPaid / post.coinPrice, ver exclusive-posts.md); la superposición solo aparece una vez que el visitante compró el acceso, y nunca para el dueño del post |
| Multimedia de post exclusivo desbloqueado (detalle del post) | PostModal.tsx | !!post?.isPaid && !!post?.hasPostAccess && authUser?.id !== post?.user?.id — misma condición que PostCard.tsx, aplicada donde el post se abre a pantalla completa (hasPostAccess, purchasePostAccess) |
| Imagen/video de mensaje de pago desbloqueado (vista del destinatario) | MessageBubble.tsx | isPaidMessage && !isMe, donde isPaidMessage = Boolean(msg.isPaid) — el destinatario solo recibe las mediaUrls reales una vez que el mensaje de pago está desbloqueado, así que llegar a esta rama de renderizado ya implica acceso; nunca se muestra al remitente |
Corregido en esta sesión: PostCard.tsx, PostModal.tsx y MessageBubble.tsx habían estado conectados a DynamicWatermark en algún momento y luego se les quitó el import/uso, dejando solo la integración de transmisiones en vivo de arriba. Los tres están reconectados ahora, cada uno protegido por pago + acceso concedido + no ser el dueño/remitente, siguiendo el mismo patrón que ya usaba la integración de transmisiones en vivo.
Notas de diseño
- Forense, no preventiva. El objetivo es la trazabilidad, no el DRM. Un usuario decidido aún puede capturar la pantalla — pero la captura lo identifica. Esto coincide con la decisión de producto de usar una marca de agua dinámica en lugar de un bloqueo de capturas (poco confiable y que rompe la UX).
- Quien ve, no el creador. La marca de agua siempre muestra a quien está mirando en ese momento, así que una captura filtrada apunta al filtrador. Por eso lee la identidad de
AuthContextal momento de renderizar, en lugar de que el creador la incruste en el multimedia. - Superposición del lado del cliente. Protege la experiencia de visualización dentro de la app. No modifica el archivo multimedia subyacente, así que quien obtenga la URL cruda del multimedia directamente no obtendría un archivo con marca de agua — la marca de agua opcional del lado del servidor / incrustada descrita arriba cierra ese vacío específico para las imágenes de posts de pago cuando
BAKE_IN_WATERMARKestá activada. - Exclusión del dueño. El transmisor de la transmisión en vivo no lleva marca de agua en su propia transmisión, ya que marcar la vista propia de un creador no sirve para el rastreo.