diff --git a/mu-plugins/fea-subir-audio-api.php b/mu-plugins/fea-subir-audio-api.php new file mode 100644 index 0000000..fa7d24b --- /dev/null +++ b/mu-plugins/fea-subir-audio-api.php @@ -0,0 +1,197 @@ +, audio=, voice_id= + * + * Ver issue gitea.feadulta.com/rafa/feadulta#222. + */ + +if (!defined('ABSPATH')) exit; + +const FEA_AUDIO_MAX_BYTES = 268435456; // 256 MiB: admite cartas largas. + +add_action('rest_api_init', function () { + register_rest_route('fea/v1', '/subir-audio', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => 'fea_subir_audio_handle', + 'permission_callback' => 'fea_subir_audio_can_call', + // La validación de post_id ocurre en el handler, después de autorizar: + // una llamada anónima siempre recibe 401 aunque omita parámetros. + 'args' => [ + 'post_id' => [ + 'sanitize_callback' => 'absint', + ], + 'voice_id' => [ + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ]); +}); + +/** Editor o superior: mismo nivel que los endpoints de escritura existentes. */ +function fea_subir_audio_can_call(WP_REST_Request $request) { + if (!is_user_logged_in()) { + return new WP_Error( + 'fea_subir_audio_not_authenticated', + 'Debes autenticarte para subir un audio.', + ['status' => 401] + ); + } + if (!current_user_can('edit_others_posts')) { + return new WP_Error( + 'fea_subir_audio_forbidden', + 'No tienes permiso para subir audios.', + ['status' => 403] + ); + } + return true; +} + +function fea_subir_audio_handle(WP_REST_Request $request) { + $post_id = absint($request->get_param('post_id')); + if (!$post_id) { + return new WP_Error( + 'fea_subir_audio_invalid_post', + 'post_id es obligatorio.', + ['status' => 400] + ); + } + if (!get_post($post_id)) { + return new WP_Error( + 'fea_subir_audio_post_not_found', + 'No existe el artículo indicado.', + ['status' => 404] + ); + } + + $files = $request->get_file_params(); + $file = $files['audio'] ?? null; + $validation = fea_subir_audio_validate_file($file); + if (is_wp_error($validation)) return $validation; + + $previous = [ + 'url' => get_post_meta($post_id, 'fea_audio_url', true) ?: null, + 'voice' => get_post_meta($post_id, 'fea_audio_voice', true) ?: null, + 'done' => get_post_meta($post_id, 'fea_audio_done', true) ?: null, + 'sha256' => get_post_meta($post_id, 'fea_audio_sha256', true) ?: null, + 'error' => get_post_meta($post_id, 'fea_audio_error', true) ?: null, + ]; + + require_once ABSPATH . 'wp-admin/includes/file.php'; + $upload_dir_filter = 'fea_subir_audio_upload_dir'; + add_filter('upload_dir', $upload_dir_filter); + try { + $uploaded = wp_handle_upload($file, [ + 'test_form' => false, + 'mimes' => ['mp3' => 'audio/mpeg'], + 'unique_filename_callback' => function ($dir, $name, $ext) use ($post_id) { + return $post_id . '.mp3'; + }, + ]); + } finally { + remove_filter('upload_dir', $upload_dir_filter); + } + if (!empty($uploaded['error']) || empty($uploaded['file'])) { + return new WP_Error( + 'fea_subir_audio_upload_failed', + 'No se pudo guardar el audio: ' . ($uploaded['error'] ?? 'error desconocido'), + ['status' => 400] + ); + } + + $sha256 = hash_file('sha256', $uploaded['file']); + if ($sha256 === false) { + wp_delete_file($uploaded['file']); + return new WP_Error( + 'fea_subir_audio_hash_failed', + 'No se pudo calcular la huella del audio subido.', + ['status' => 500] + ); + } + + // El path es estable para que los players y el proceso de backfill no dependan + // de las carpetas por mes que normalmente crea WordPress. + $audio_url = home_url('/wp-content/uploads/tts/' . $post_id . '.mp3'); + update_post_meta($post_id, 'fea_audio_url', $audio_url); + update_post_meta($post_id, 'fea_audio_voice', (string) $request->get_param('voice_id')); + update_post_meta($post_id, 'fea_audio_done', '1'); + update_post_meta($post_id, 'fea_audio_sha256', $sha256); + delete_post_meta($post_id, 'fea_audio_error'); + + return new WP_REST_Response([ + 'post_id' => $post_id, + 'audio_url' => $audio_url, + 'voice_id' => (string) $request->get_param('voice_id'), + 'sha256' => $sha256, + 'previous_audio' => $previous, + ], 201); +} + +/** Guarda el binario en uploads/tts sin cambiar globalmente la configuración. */ +function fea_subir_audio_upload_dir($uploads) { + $uploads['subdir'] = '/tts'; + $uploads['path'] = $uploads['basedir'] . $uploads['subdir']; + $uploads['url'] = $uploads['baseurl'] . $uploads['subdir']; + if (!wp_mkdir_p($uploads['path'])) { + $uploads['error'] = 'No se pudo crear el directorio de audios.'; + } + return $uploads; +} + +function fea_subir_audio_validate_file($file) { + if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) { + return new WP_Error( + 'fea_subir_audio_missing_file', + 'audio es obligatorio.', + ['status' => 400] + ); + } + if ((int) $file['error'] !== UPLOAD_ERR_OK) { + return new WP_Error( + 'fea_subir_audio_file_error', + 'La subida de audio falló.', + ['status' => 400] + ); + } + if ((int) $file['size'] <= 0 || (int) $file['size'] > FEA_AUDIO_MAX_BYTES) { + return new WP_Error( + 'fea_subir_audio_file_too_large', + 'El audio debe ocupar entre 1 byte y 256 MiB.', + ['status' => 400] + ); + } + + $type = wp_check_filetype((string) ($file['name'] ?? ''), ['mp3' => 'audio/mpeg']); + if (($type['ext'] ?? '') !== 'mp3' || ($type['type'] ?? '') !== 'audio/mpeg') { + return new WP_Error( + 'fea_subir_audio_unsupported_type', + 'Solo se admiten archivos MP3 (audio/mpeg).', + ['status' => 400] + ); + } + if (strtolower((string) ($file['type'] ?? '')) !== 'audio/mpeg') { + return new WP_Error( + 'fea_subir_audio_unsupported_type', + 'audio debe declararse como audio/mpeg.', + ['status' => 400] + ); + } + + if (function_exists('finfo_open')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = $finfo ? finfo_file($finfo, $file['tmp_name']) : false; + if ($finfo) finfo_close($finfo); + if ($mime !== false && $mime !== 'audio/mpeg') { + return new WP_Error( + 'fea_subir_audio_invalid_audio', + 'audio debe contener datos MPEG válidos.', + ['status' => 400] + ); + } + } + return true; +} diff --git a/tests/integration/test_subir_audio_api.sh b/tests/integration/test_subir_audio_api.sh new file mode 100755 index 0000000..2973a36 --- /dev/null +++ b/tests/integration/test_subir_audio_api.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Integración local para POST /wp-json/fea/v1/subir-audio (#222). +# Requiere FEA_TEST_URL, FEA_TEST_AUTH y FEA_TEST_POST_ID (post temporal). +set -euo pipefail + +: "${FEA_TEST_URL:?Falta FEA_TEST_URL}" +: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}" +: "${FEA_TEST_POST_ID:?Falta FEA_TEST_POST_ID}" + +endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/subir-audio" +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +# MP3 pequeño y válido (silencio MPEG-1 Layer III), sin depender de ffmpeg. +printf '\377\373\220\144' > "$tmpdir/audio.mp3" +dd if=/dev/zero bs=1 count=413 >> "$tmpdir/audio.mp3" 2>/dev/null +printf 'no es un mp3' > "$tmpdir/not-audio.txt" + +assert_status() { + local expected="$1" actual="$2" label="$3" response_file="$4" + if [[ "$actual" != "$expected" ]]; then + echo "FAIL: $label — esperado HTTP $expected, recibido $actual" >&2 + cat "$response_file" >&2 || true + exit 1 + fi +} + +status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")" +assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json" + +status="$(curl -sS -o "$tmpdir/no-post.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -X POST "$endpoint")" +assert_status 400 "$status" 'falta post_id' "$tmpdir/no-post.json" + +status="$(curl -sS -o "$tmpdir/no-audio.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" "$endpoint")" +assert_status 400 "$status" 'falta audio' "$tmpdir/no-audio.json" + +status="$(curl -sS -o "$tmpdir/wrong-type.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F "audio=@$tmpdir/not-audio.txt;type=text/plain" "$endpoint")" +assert_status 400 "$status" 'extensión o mime no MP3' "$tmpdir/wrong-type.json" + +status="$(curl -sS -o "$tmpdir/no-such-post.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'post_id=999999999' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")" +assert_status 404 "$status" 'post inexistente' "$tmpdir/no-such-post.json" + +status="$(curl -sS -o "$tmpdir/success.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F 'voice_id=NicoFeadulta2026' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")" +assert_status 201 "$status" 'subida válida' "$tmpdir/success.json" + +python3 - "$tmpdir/success.json" "$FEA_TEST_POST_ID" "$tmpdir/audio.mp3" <<'PY' +import hashlib, json, sys +payload = json.load(open(sys.argv[1])) +assert int(payload['post_id']) == int(sys.argv[2]), payload +assert payload['audio_url'].endswith(f'/wp-content/uploads/tts/{sys.argv[2]}.mp3'), payload +assert payload['voice_id'] == 'NicoFeadulta2026', payload +assert payload['sha256'] == hashlib.sha256(open(sys.argv[3], 'rb').read()).hexdigest(), payload +assert 'previous_audio' in payload, payload +print('PASS: audio subido', payload['audio_url']) +PY + +# La siguiente subida del mismo post debe exponer el estado anterior, para que +# el cliente pueda restaurarlo si su flujo posterior falla. +status="$(curl -sS -o "$tmpdir/replaced.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F 'voice_id=NicoFeadulta2026' -F "audio=@$tmpdir/audio.mp3;type=audio/mpeg" "$endpoint")" +assert_status 201 "$status" 'reemplazo de audio' "$tmpdir/replaced.json" + +python3 - "$tmpdir/replaced.json" "$FEA_TEST_POST_ID" <<'PY' +import json, sys +payload = json.load(open(sys.argv[1])) +assert payload['previous_audio']['url'].endswith(f'/wp-content/uploads/tts/{sys.argv[2]}.mp3'), payload +assert payload['previous_audio']['done'] == '1', payload +print('PASS: estado anterior devuelto') +PY