From 15d3d72c70c87a169a100515b3693fb7b7101e4f Mon Sep 17 00:00:00 2001 From: rafa Date: Thu, 16 Jul 2026 07:06:55 -0400 Subject: [PATCH] feat(api): endpoint para subir avatar de autor (#175) --- docs/plans/2026-07-16-subir-avatar-api-175.md | 55 ++++ mu-plugins/fea-subir-avatar-api.php | 247 ++++++++++++++++++ tests/integration/test_subir_avatar_api.sh | 90 +++++++ 3 files changed, 392 insertions(+) create mode 100644 docs/plans/2026-07-16-subir-avatar-api-175.md create mode 100644 mu-plugins/fea-subir-avatar-api.php create mode 100755 tests/integration/test_subir_avatar_api.sh diff --git a/docs/plans/2026-07-16-subir-avatar-api-175.md b/docs/plans/2026-07-16-subir-avatar-api-175.md new file mode 100644 index 0000000..c8446f0 --- /dev/null +++ b/docs/plans/2026-07-16-subir-avatar-api-175.md @@ -0,0 +1,55 @@ +# API de subida de avatar (#175) — Plan de implementación + +> **For Hermes:** implementar por pasos pequeños, con prueba de integración local antes de tocar el código de producción. + +**Objetivo:** permitir que Mixbot/Inma asigne o reemplace de forma segura la foto de un autor mediante `POST /wp-json/fea/v1/subir-avatar`, sin depender del wp-admin ni de Rafa. + +**Arquitectura:** un mu-plugin nuevo y autónomo en `mu-plugins/`, paralelo a `fea-crear-autor-api.php`. Reutiliza el mismo modelo de autenticación (Application Password autenticada + capacidad `edit_others_posts`). Recibe un `multipart/form-data` con `user_id` exacto e imagen en el campo `avatar`; valida, normaliza a un cuadrado de 512 px, crea el attachment de WordPress y actualiza únicamente el meta ACF `foto_perfil`. + +**Decisiones deliberadas de contrato:** +- **Multipart**, no base64: evita codificación innecesaria y usa el manejo seguro nativo de uploads de WordPress. +- **`user_id` obligatorio**, no slug: evita ambigüedades al asignar una imagen a una persona. +- **JPEG, PNG y WebP; máximo 5 MB; mínimo 512×512 px; máximo 4096 px por lado y 16 megapíxeles.** +- Se conserva el attachment previo; la respuesta devuelve `previous_attachment_id` para rollback manual. No se borra ningún avatar anterior. +- La imagen se recorta centrada y se normaliza a **512×512 px** en el servidor. + +**Alcance de seguridad:** el endpoint no permite elegir meta, ruta de filesystem, MIME arbitrario ni otro usuario distinto del `user_id` explícito. Requiere autenticación y permiso de editor o superior. + +--- + +### Task 1: Añadir prueba de integración local para el contrato vacío + +**Files:** +- Create: `tests/integration/test_subir_avatar_api.sh` + +**Step 1:** probar contra el WordPress Docker local que la ruta no está disponible antes de cargar el nuevo mu-plugin. + +**Step 2:** el script debe cubrir, tras cargar el plugin: falta de autenticación (401), falta de `user_id` (400), usuario inexistente (404), falta de fichero (400) y una subida correcta a un usuario temporal. + +**Step 3:** el caso correcto debe verificar respuesta, attachment creado, meta `foto_perfil` actualizado y que el attachment anterior no se elimina. Debe limpiar el usuario/attachment temporal al terminar. + +### Task 2: Implementar el mu-plugin mínimo + +**Files:** +- Create: `mu-plugins/fea-subir-avatar-api.php` + +**Step 1:** registrar `POST /fea/v1/subir-avatar` y reutilizar una callback de autorización equivalente a la de `crear-autor`. + +**Step 2:** validar `user_id` y el fichero `avatar` antes de persistir nada. + +**Step 3:** procesar la imagen con APIs nativas de WordPress, normalizarla a 512×512 y crear su attachment con metadata. + +**Step 4:** actualizar exclusivamente `foto_perfil` y devolver ID del usuario, attachment nuevo, attachment anterior y URL. + +### Task 3: Ejecutar integración local y revisión de seguridad + +**Files:** +- Modify only if a test demuestra una necesidad real. + +**Step 1:** copiar temporalmente el mu-plugin y el script de prueba a la instalación WordPress Docker local. No tocar producción. + +**Step 2:** ejecutar la prueba y verificar que falla de forma esperada antes de implementar, y pasa después. + +**Step 3:** ejecutar `php -l` sobre el mu-plugin y revisar `git diff --check` / `git diff`. + +**Step 4:** dejar los cambios solo en la rama aislada `feat/subir-avatar-175`; no hacer push, PR ni despliegue sin aprobación explícita de Rafa. diff --git a/mu-plugins/fea-subir-avatar-api.php b/mu-plugins/fea-subir-avatar-api.php new file mode 100644 index 0000000..7a1d7a9 --- /dev/null +++ b/mu-plugins/fea-subir-avatar-api.php @@ -0,0 +1,247 @@ +, avatar= + * + * Ver issue gitea.feadulta.com/rafa/feadulta#175. + */ + +if (!defined('ABSPATH')) exit; + +const FEA_AVATAR_MAX_BYTES = 5242880; // 5 MiB. +const FEA_AVATAR_SIZE = 512; +const FEA_AVATAR_MIN_SIZE = 512; +const FEA_AVATAR_MAX_DIMENSION = 4096; +const FEA_AVATAR_MAX_PIXELS = 16777216; // 16 megapíxeles. + +add_action('rest_api_init', function () { + register_rest_route('fea/v1', '/subir-avatar', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => 'fea_subir_avatar_handle', + 'permission_callback' => 'fea_subir_avatar_can_call', + // Se valida dentro del handler, después del permission_callback: una + // llamada sin autenticar siempre recibe 401, incluso si omite user_id. + 'args' => [ + 'user_id' => [ + 'sanitize_callback' => 'absint', + ], + ], + ]); +}); + +/** Editor o superior: mismo nivel que el endpoint /crear-autor. */ +function fea_subir_avatar_can_call(WP_REST_Request $request) { + if (!is_user_logged_in()) { + return new WP_Error( + 'fea_subir_avatar_not_authenticated', + 'Debes autenticarte para asignar un avatar.', + ['status' => 401] + ); + } + if (!current_user_can('edit_others_posts')) { + return new WP_Error( + 'fea_subir_avatar_forbidden', + 'No tienes permiso para asignar avatares.', + ['status' => 403] + ); + } + return true; +} + +function fea_subir_avatar_handle(WP_REST_Request $request) { + $user_id = absint($request->get_param('user_id')); + if (!$user_id) { + return new WP_Error( + 'fea_subir_avatar_invalid_user', + 'user_id es obligatorio.', + ['status' => 400] + ); + } + + $user = get_userdata($user_id); + if (!$user) { + return new WP_Error( + 'fea_subir_avatar_user_not_found', + 'No existe el usuario indicado.', + ['status' => 404] + ); + } + + $files = $request->get_file_params(); + $file = $files['avatar'] ?? null; + $validation = fea_subir_avatar_validate_file($file); + if (is_wp_error($validation)) return $validation; + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $uploaded = wp_handle_upload($file, [ + 'test_form' => false, + 'mimes' => [ + 'jpg|jpeg|jpe' => 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + ], + ]); + if (!empty($uploaded['error'])) { + return new WP_Error( + 'fea_subir_avatar_upload_failed', + 'No se pudo guardar la imagen: ' . $uploaded['error'], + ['status' => 400] + ); + } + + $normalized = fea_subir_avatar_normalize($uploaded['file']); + if (is_wp_error($normalized)) { + wp_delete_file($uploaded['file']); + return $normalized; + } + + $attachment_id = wp_insert_attachment([ + 'post_mime_type' => $normalized['mime-type'], + 'post_title' => 'Avatar — ' . $user->display_name, + 'post_status' => 'inherit', + ], $normalized['path'], 0, true); + if (is_wp_error($attachment_id)) { + wp_delete_file($normalized['path']); + return new WP_Error( + 'fea_subir_avatar_attachment_failed', + 'No se pudo crear el attachment del avatar.', + ['status' => 500] + ); + } + + $metadata = wp_generate_attachment_metadata($attachment_id, $normalized['path']); + if (is_wp_error($metadata) || !is_array($metadata) || !$metadata) { + wp_delete_attachment($attachment_id, true); + return new WP_Error( + 'fea_subir_avatar_metadata_failed', + 'No se pudo generar la metadata del avatar.', + ['status' => 500] + ); + } + wp_update_attachment_metadata($attachment_id, $metadata); + if (!wp_get_attachment_metadata($attachment_id)) { + wp_delete_attachment($attachment_id, true); + return new WP_Error( + 'fea_subir_avatar_metadata_failed', + 'No se pudo guardar la metadata del avatar.', + ['status' => 500] + ); + } + + $previous_attachment_id = (int) get_user_meta($user_id, 'foto_perfil', true); + if (!update_user_meta($user_id, 'foto_perfil', (string) $attachment_id)) { + wp_delete_attachment($attachment_id, true); + return new WP_Error( + 'fea_subir_avatar_assignment_failed', + 'No se pudo asignar el avatar al usuario.', + ['status' => 500] + ); + } + + $size = wp_getimagesize($normalized['path']); + return new WP_REST_Response([ + 'user_id' => $user_id, + 'attachment_id' => (int) $attachment_id, + 'previous_attachment_id' => $previous_attachment_id ?: null, + 'avatar_url' => wp_get_attachment_image_url($attachment_id, 'full'), + 'width' => (int) ($size[0] ?? 0), + 'height' => (int) ($size[1] ?? 0), + ], 201); +} + +function fea_subir_avatar_validate_file($file) { + if (!is_array($file) || empty($file['tmp_name']) || !isset($file['error'])) { + return new WP_Error( + 'fea_subir_avatar_missing_file', + 'avatar es obligatorio.', + ['status' => 400] + ); + } + if ((int) $file['error'] !== UPLOAD_ERR_OK) { + return new WP_Error( + 'fea_subir_avatar_file_error', + 'La subida de avatar falló.', + ['status' => 400] + ); + } + if ((int) $file['size'] > FEA_AVATAR_MAX_BYTES) { + return new WP_Error( + 'fea_subir_avatar_file_too_large', + 'La imagen no puede superar 5 MB.', + ['status' => 400] + ); + } + + $image = wp_getimagesize($file['tmp_name']); + if (!$image) { + return new WP_Error( + 'fea_subir_avatar_invalid_image', + 'avatar debe ser una imagen válida.', + ['status' => 400] + ); + } + + $width = (int) $image[0]; + $height = (int) $image[1]; + if ($width < FEA_AVATAR_MIN_SIZE || $height < FEA_AVATAR_MIN_SIZE) { + return new WP_Error( + 'fea_subir_avatar_invalid_image', + 'avatar debe ser una imagen de al menos 512×512 píxeles.', + ['status' => 400] + ); + } + if ($width > FEA_AVATAR_MAX_DIMENSION || $height > FEA_AVATAR_MAX_DIMENSION || ($width * $height) > FEA_AVATAR_MAX_PIXELS) { + return new WP_Error( + 'fea_subir_avatar_image_too_large', + 'avatar no puede superar 4096 píxeles por lado ni 16 megapíxeles.', + ['status' => 400] + ); + } + + $allowed_mimes = ['image/jpeg', 'image/png', 'image/webp']; + if (!in_array($image['mime'], $allowed_mimes, true)) { + return new WP_Error( + 'fea_subir_avatar_unsupported_type', + 'Solo se admiten imágenes JPEG, PNG o WebP.', + ['status' => 400] + ); + } + return true; +} + +/** Recorta al centro y normaliza la imagen al tamaño estándar del avatar. */ +function fea_subir_avatar_normalize(string $path) { + $editor = wp_get_image_editor($path); + if (is_wp_error($editor)) { + return new WP_Error( + 'fea_subir_avatar_editor_unavailable', + 'No se pudo procesar la imagen subida.', + ['status' => 400] + ); + } + + $resized = $editor->resize(FEA_AVATAR_SIZE, FEA_AVATAR_SIZE, true); + if (is_wp_error($resized)) { + return new WP_Error( + 'fea_subir_avatar_resize_failed', + 'No se pudo normalizar el avatar.', + ['status' => 400] + ); + } + + $saved = $editor->save($path); + if (is_wp_error($saved) || empty($saved['path'])) { + return new WP_Error( + 'fea_subir_avatar_save_failed', + 'No se pudo guardar el avatar normalizado.', + ['status' => 500] + ); + } + return $saved; +} diff --git a/tests/integration/test_subir_avatar_api.sh b/tests/integration/test_subir_avatar_api.sh new file mode 100755 index 0000000..355665e --- /dev/null +++ b/tests/integration/test_subir_avatar_api.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Integración local para POST /wp-json/fea/v1/subir-avatar (#175). +# Requiere: +# FEA_TEST_URL=http://localhost:8081 +# FEA_TEST_AUTH='usuario:application-password' +# FEA_TEST_USER_ID= +set -euo pipefail + +: "${FEA_TEST_URL:?Falta FEA_TEST_URL}" +: "${FEA_TEST_AUTH:?Falta FEA_TEST_AUTH}" +: "${FEA_TEST_USER_ID:?Falta FEA_TEST_USER_ID}" + +endpoint="${FEA_TEST_URL%/}/wp-json/fea/v1/subir-avatar" +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +# PNG RGB válido de 640×640, generado sin dependencias externas. +python3 - "$tmpdir/avatar.png" <<'PY' +import struct, sys, zlib +width = height = 640 +raw = b''.join(b'\x00' + bytes((35, 100, 180)) * width for _ in range(height)) +def chunk(kind, data): + return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff) +png = b'\x89PNG\r\n\x1a\n' +png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) +png += chunk(b'IDAT', zlib.compress(raw, 9)) +png += chunk(b'IEND', b'') +open(sys.argv[1], 'wb').write(png) +PY + +# PNG válido pero con una dimensión que excede el máximo permitido; queda muy +# comprimido a propósito para cubrir la defensa contra image bombs. +python3 - "$tmpdir/too-wide.png" <<'PY' +import struct, sys, zlib +width, height = 4097, 512 +raw = b''.join(b'\x00' + bytes((35, 100, 180)) * width for _ in range(height)) +def chunk(kind, data): + return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff) +png = b'\x89PNG\r\n\x1a\n' +png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)) +png += chunk(b'IDAT', zlib.compress(raw, 9)) +png += chunk(b'IEND', b'') +open(sys.argv[1], 'wb').write(png) +PY + +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 +} + +# La ruta existe y no permite una llamada sin autenticar. +status="$(curl -sS -o "$tmpdir/unauth.json" -w '%{http_code}' -X POST "$endpoint")" +assert_status 401 "$status" 'llamada sin autenticar' "$tmpdir/unauth.json" + +# Autenticada, pero sin destino: no puede persistir nada. +status="$(curl -sS -o "$tmpdir/no-user.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -X POST "$endpoint")" +assert_status 400 "$status" 'falta user_id' "$tmpdir/no-user.json" + +# Un usuario válido sin imagen no puede crear attachments vacíos. +status="$(curl -sS -o "$tmpdir/no-avatar.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" "$endpoint")" +assert_status 400 "$status" 'falta avatar' "$tmpdir/no-avatar.json" + +# Una imagen-bomba comprimida se rechaza por dimensiones antes de abrir el editor. +status="$(curl -sS -o "$tmpdir/too-wide.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" -F "avatar=@$tmpdir/too-wide.png;type=image/png" "$endpoint")" +assert_status 400 "$status" 'dimensiones excesivas' "$tmpdir/too-wide.json" + +# Usuario inexistente: no puede crear attachments huérfanos. +status="$(curl -sS -o "$tmpdir/no-such-user.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F 'user_id=999999999' -F "avatar=@$tmpdir/avatar.png;type=image/png" "$endpoint")" +assert_status 404 "$status" 'usuario inexistente' "$tmpdir/no-such-user.json" + +# Camino feliz: el endpoint crea attachment y devuelve el avatar asignado. +status="$(curl -sS -o "$tmpdir/success.json" -w '%{http_code}' -u "$FEA_TEST_AUTH" -F "user_id=$FEA_TEST_USER_ID" -F "avatar=@$tmpdir/avatar.png;type=image/png" "$endpoint")" +assert_status 201 "$status" 'subida válida' "$tmpdir/success.json" + +python3 - "$tmpdir/success.json" "$FEA_TEST_USER_ID" <<'PY' +import json, os, sys +payload = json.load(open(sys.argv[1])) +assert int(payload['user_id']) == int(sys.argv[2]), payload +assert int(payload['attachment_id']) > 0, payload +assert payload['avatar_url'].startswith(('http://', 'https://')), payload +assert payload['width'] == 512 and payload['height'] == 512, payload +previous = os.environ.get('FEA_TEST_PREVIOUS_ATTACHMENT_ID') +if previous: + assert int(payload['previous_attachment_id']) == int(previous), payload +print('PASS: avatar asignado', payload['attachment_id']) +PY