From bc3af3cbb95b12d17ee3db054c8b129bd6c4a4f9 Mon Sep 17 00:00:00 2001 From: rafa Date: Thu, 20 Aug 2026 11:50:00 -0400 Subject: [PATCH] Verify complete audio assets before browser playback --- mu-plugins/fea-audio-player.php | 74 +++++++++++++++++++++++++++------ scripts/sync_audio_to_prod.py | 15 +++++-- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/mu-plugins/fea-audio-player.php b/mu-plugins/fea-audio-player.php index b8642e2..9394070 100644 --- a/mu-plugins/fea-audio-player.php +++ b/mu-plugins/fea-audio-player.php @@ -13,11 +13,23 @@ function fea_audio_player_html(): string { $url = get_post_meta($id, 'fea_audio_url', true); if (!$url) return ''; $voice = get_post_meta($id, 'fea_audio_voice', true) ?: 'NicoFeadulta2026'; - return '
' - . '' + $sha256 = get_post_meta($id, 'fea_audio_sha256', true); + $label = '' . ' Escucha' + . ' Escucha'; + // Para un asset con hash registrado, descarga y verifica el fichero completo + // antes de delegar playback al navegador. Evita que un stream Range mezclado + // por una caché reproduzca fragmentos de otro MP3. + if (preg_match('/^[a-f0-9]{64}$/i', $sha256)) { + return '
' . $label + . '' + . '' + . '
'; + } + return '
' . $label . '' . '
'; @@ -56,6 +68,9 @@ add_action('wp_head', function () { .fea-audio-label{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem; font-weight:600;color:#8b1a2e;white-space:nowrap;line-height:1} .fea-audio audio{height:32px;width:230px;max-width:44vw} + .fea-audio-verified-play{border:1px solid #8b1a2e;border-radius:6px;background:#fff;color:#8b1a2e;padding:.35rem .6rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer} + .fea-audio-verified-play:disabled{opacity:.65;cursor:wait} + .fea-audio-status{font-size:.76rem;color:#6b5c60} @media(max-width:600px){ .fea-audio{margin-left:0;width:100%;margin-top:.5rem} .fea-audio audio{flex:1 1 auto;width:auto;max-width:none} @@ -73,17 +88,52 @@ add_action('wp_footer', function () { if (!get_post_meta(get_queried_object_id(), 'fea_audio_url', true)) return; ?> str: return _ssh_text(_remote_wrap(inner), timeout=60) -def prod_upload_mp3(post_id: int) -> None: +def prod_upload_mp3(post_id: int) -> str: src = LOCAL_TTS_DIR / f"{post_id}.mp3" data = src.read_bytes() + local_sha256 = hashlib.sha256(data).hexdigest() remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3" _ssh_upload_bytes(data, remote_path) - # Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe - # correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap. + # Verificación de tamaño+contenido: un rc=0 de ssh no acredita que el asset + # sea el mismo. El hash se persiste como meta y permite playback verificado. remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip()) if remote_size != len(data): raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}") + remote_sha256 = _ssh_text(_remote_wrap(f"sha256sum {remote_path} | cut -d' ' -f1")).strip() + if remote_sha256 != local_sha256: + raise RuntimeError(f"SHA-256 no coincide tras subir #{post_id}: local={local_sha256} remoto={remote_sha256}") + return local_sha256 def prod_remove_mp3(post_id: int) -> None: @@ -185,8 +191,9 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str: return "PLAN: subiría mp3 + setaudio" voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026" - prod_upload_mp3(post_id) + sha256 = prod_upload_mp3(post_id) prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice) + prod_helper("setflag", str(post_id), "fea_audio_sha256", sha256) if post_id not in state["synced"]: state["synced"].append(post_id) save_state(state) -- 2.52.0