Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d3d72c70 | |||
| 809d4d9b81 |
@@ -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.
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: Fe Adulta — API subir avatar
|
||||
* Description: Endpoint REST acotado para asignar la foto de perfil de un autor.
|
||||
* Version: 1.0
|
||||
*
|
||||
* POST /wp-json/fea/v1/subir-avatar
|
||||
* multipart/form-data: user_id=<id>, avatar=<imagen JPEG|PNG|WebP>
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
Executable
+90
@@ -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=<usuario temporal existente>
|
||||
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
|
||||
Reference in New Issue
Block a user