diff --git a/wordpress/wp-content/mu-plugins/fea-security-blocked-users.php b/wordpress/wp-content/mu-plugins/fea-security-blocked-users.php new file mode 100644 index 0000000..62e235f --- /dev/null +++ b/wordpress/wp-content/mu-plugins/fea-security-blocked-users.php @@ -0,0 +1,27 @@ +ID)) { + return new WP_Error('fea_account_disabled', __('This account is disabled.')); + } + + return $user; +}, 99); + +add_filter('allow_password_reset', static function (bool $allow, int $user_id): bool { + return fea_security_is_disabled_user($user_id) ? false : $allow; +}, 99, 2); diff --git a/wordpress/wp-content/mu-plugins/fea-subir-avatar-api.php b/wordpress/wp-content/mu-plugins/fea-subir-avatar-api.php new file mode 100644 index 0000000..7a1d7a9 --- /dev/null +++ b/wordpress/wp-content/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; +}