Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| babff260a1 | |||
| 0abaacfcc0 | |||
| 84cb370742 |
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Fe Adulta — API subir audio TTS
|
||||||
|
* Description: Endpoint REST acotado para publicar el MP3 TTS de un artículo.
|
||||||
|
* Version: 1.0
|
||||||
|
*
|
||||||
|
* POST /wp-json/fea/v1/subir-audio
|
||||||
|
* multipart/form-data: post_id=<id>, audio=<MP3>, voice_id=<opcional>
|
||||||
|
*
|
||||||
|
* Ver issue gitea.feadulta.com/rafa/feadulta#222.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
const FEA_AUDIO_MAX_BYTES = 26214400; // 25 MiB: límite operativo de producción.
|
||||||
|
|
||||||
|
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 25 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;
|
||||||
|
}
|
||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/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"
|
||||||
|
# Un byte por encima del límite de aplicación de 25 MiB. Se comprueba antes
|
||||||
|
# del tipo de fichero, por lo que puede ser disperso y no necesita ser audio.
|
||||||
|
truncate -s 26214401 "$tmpdir/too-large.mp3"
|
||||||
|
|
||||||
|
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/too-large.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "post_id=$FEA_TEST_POST_ID" -F "audio=@$tmpdir/too-large.mp3;type=audio/mpeg" "$endpoint")"
|
||||||
|
assert_status 400 "$status" 'audio por encima de 25 MiB' "$tmpdir/too-large.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
|
||||||
Reference in New Issue
Block a user