, 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; }