Constaia
Integraciones

WordPress

Valida documentos en WordPress con un plugin basado en constaia/constaia-php, con shortcode, ruta REST protegida con nonce y perfiles en el servidor.

El repositorio del SDK de PHP incluye un plugin de ejemplo, examples/wordpress/constaia-upload.php, que añade un shortcode con el widget y una ruta REST que llama a Constaia desde el servidor. Esta guía lo instala, explica cómo funciona y cómo ampliarlo con tus propios perfiles y un webhook.

Es un plugin de ejemplo para copiar y adaptar, no un plugin publicado en el directorio de WordPress.org. Lo instalas tú en wp-content/plugins y lo mantienes como parte de tu sitio.

Requisitos

  • WordPress con PHP ≥ 8.1 (ext-curl y ext-json) y acceso a Composer en el servidor o en tu máquina de despliegue.
  • Una clave de test ck_test_… del panel. En modo test no se consumen créditos y el resultado depende del nombre del fichero.
  • Usuarios con sesión iniciada: la ruta solo admite usuarios autenticados.

Instalación

Crea la carpeta del plugin e instala el SDK

mkdir -p wp-content/plugins/constaia-upload
cd wp-content/plugins/constaia-upload
composer require constaia/constaia-php

Copia el plugin

Guarda el código de la sección siguiente como wp-content/plugins/constaia-upload/constaia-upload.php.

Añade la clave a wp-config.php

wp-config.php
define('CONSTAIA_API_KEY', 'ck_test_...');
define('CONSTAIA_WEBHOOK_SECRET', 'whsec_...'); // solo si añades el webhook

Ponlo antes de la línea /* That's all, stop editing! */. La clave nunca se imprime en la página: solo la usa el servidor. Si prefieres variables de entorno, usa define('CONSTAIA_API_KEY', getenv('CONSTAIA_API_KEY'));.

Activa el plugin

En Plugins activa "Constaia Upload" (o wp plugin activate constaia-upload con WP-CLI) y añade el shortcode a una página:

[constaia_upload profile="dni"]

El plugin

Este es el plugin de ejemplo. El script del widget se carga con la build IIFE (dist/cdn/constaia-widget.iife.min.js), que es la que funciona con wp_enqueue_script (un <script> clásico).

wp-content/plugins/constaia-upload/constaia-upload.php
<?php
/**
 * Plugin Name:       Constaia Upload
 * Description:       Document validation with Constaia: [constaia_upload profile="dni"] shortcode + REST route constaia/v1/analyze.
 * Version:           0.1.0
 * Requires PHP:      8.1
 * License:           MIT
 */

declare(strict_types=1);

if (!defined('ABSPATH')) {
    exit;
}

require_once __DIR__ . '/vendor/autoload.php';

/**
 * What each profile validates. Decided here, on the server: the browser only picks a profile name.
 * Extend it with add_filter('constaia_profiles', …).
 */
function constaia_profiles(): array
{
    return apply_filters('constaia_profiles', [
        'dni' => [
            'expect' => ['es_dni', 'es_nie', 'passport'],
            'checks' => ['not_expired' => true],
        ],
        'medical' => [
            'expect' => 'medical_certificate_sport',
            'checks' => ['max_age_days' => 365, 'require_signature' => true],
        ],
    ]);
}

function constaia_client(): Constaia\Client
{
    static $client = null;
    $key = defined('CONSTAIA_API_KEY') ? CONSTAIA_API_KEY : null;

    return $client ??= new Constaia\Client($key, ['timeout' => 60]);
}

add_action('rest_api_init', static function (): void {
    register_rest_route('constaia/v1', '/analyze', [
        'methods' => 'POST',
        'callback' => 'constaia_rest_analyze',
        // Standard REST cookie auth: WordPress validates the X-WP-Nonce header (wp_rest) and, without
        // a valid nonce, treats the request as anonymous, so is_user_logged_in() is false.
        'permission_callback' => static function (): bool {
            return is_user_logged_in() && current_user_can('read');
        },
        'args' => [
            'profile' => ['type' => 'string', 'required' => true, 'enum' => array_keys(constaia_profiles())],
        ],
    ]);
});

function constaia_rest_analyze(WP_REST_Request $request)
{
    $profile = constaia_profiles()[$request->get_param('profile')] ?? null;
    $file = $request->get_file_params()['file'] ?? null;

    if ($profile === null || !is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        return constaia_rest_error('invalid_request', __('No document received.', 'constaia'), 400);
    }
    if (!is_uploaded_file($file['tmp_name']) || $file['size'] > 20 * 1024 * 1024) {
        return constaia_rest_error('file_too_large', __('The file is too large (max. 20 MB).', 'constaia'), 400);
    }

    // Only the language is taken from the widget; expect/checks come from the server-side profile.
    $clientOptions = json_decode((string) $request->get_param('options'), true);
    $language = is_array($clientOptions) && in_array($clientOptions['language'] ?? '', ['es', 'en', 'pt', 'fr'], true)
        ? $clientOptions['language']
        : 'es';

    try {
        $analysis = constaia_client()->analyze($file['tmp_name'], $profile + [
            'filename' => sanitize_file_name($file['name']),
            'storage' => 'none',
            'language' => $language,
            'metadata' => ['wp_user_id' => (string) get_current_user_id()],
        ]);
    } catch (Constaia\Exception\ConstaiaException $e) {
        error_log('[constaia] ' . $e->getMessage() . ' request_id=' . $e->requestId);

        return constaia_rest_error('analysis_failed', __('The document could not be analysed. Please try again.', 'constaia'), 502);
    }

    do_action('constaia_analysis', $analysis, get_current_user_id());

    return rest_ensure_response([
        'id' => $analysis->id,
        'object' => 'analysis',
        'status' => $analysis->status,
        'document' => $analysis->document?->toArray(),
        'verdict' => $analysis->verdict?->toArray(),
        'warnings' => $analysis->warnings ?? [],
    ]);
}

function constaia_rest_error(string $code, string $message, int $status): WP_REST_Response
{
    return new WP_REST_Response(['error' => ['code' => $code, 'message' => $message]], $status);
}

add_shortcode('constaia_upload', static function ($atts): string {
    $atts = shortcode_atts(['profile' => 'dni'], $atts, 'constaia_upload');
    if (!is_user_logged_in()) {
        return '<p>' . esc_html__('Log in to upload your document.', 'constaia') . '</p>';
    }
    if (!isset(constaia_profiles()[$atts['profile']])) {
        return '';
    }

    wp_enqueue_script(
        'constaia-widget',
        'https://cdn.jsdelivr.net/npm/@constaia/widget@0.1/dist/cdn/constaia-widget.iife.min.js',
        [],
        '0.1.0',
        true
    );

    $endpoint = add_query_arg(
        ['profile' => $atts['profile']],
        rest_url('constaia/v1/analyze')
    );
    $headers = wp_json_encode(['X-WP-Nonce' => wp_create_nonce('wp_rest')]);

    return sprintf(
        '<constaia-upload endpoint="%s" headers="%s" lang="%s"></constaia-upload>',
        esc_url($endpoint),
        esc_attr((string) $headers),
        esc_attr(substr(get_locale(), 0, 2))
    );
});

Cómo funciona

  • La clave: se lee de la constante CONSTAIA_API_KEY de wp-config.php y solo la usa constaia_client() en el servidor. El navegador nunca la ve; el widget rechaza cualquier atributo que parezca una clave.
  • El shortcode [constaia_upload profile="dni"] solo se pinta para usuarios con sesión. Renderiza <constaia-upload> apuntando a /wp-json/constaia/v1/analyze?profile=dni y le pasa el nonce wp_rest en el atributo headers, que el widget envía como cabecera X-WP-Nonce.
  • La ruta REST POST /wp-json/constaia/v1/analyze usa la autenticación por cookie estándar de WordPress: sin un nonce válido la petición es anónima y permission_callback la rechaza. El parámetro profile solo admite los perfiles definidos.
  • Los perfiles deciden expect y checks en el servidor. Del widget solo se toma language; cualquier otra opción que envíe el navegador se ignora.
  • La respuesta devuelve solo id, status, document, verdict y warnings: lo que el widget necesita para pintar el resultado. Los campos extraídos no salen del servidor.
  • La acción constaia_analysis se dispara con el análisis y el id del usuario para que guardes el resultado.

Añadir perfiles con constaia_profiles

Amplía o cambia los perfiles desde tu tema o desde otro plugin con el filtro constaia_profiles. Como el filtro se evalúa en cada petición, puedes usar datos del usuario actual, por ejemplo para comprobar el titular.

wp-content/mu-plugins/constaia-profiles.php
<?php

add_filter('constaia_profiles', static function (array $profiles): array {
    $user = wp_get_current_user();

    $profiles['dni'] = [
        'expect' => ['es_dni', 'es_nie', 'passport'],
        'checks' => [
            'not_expired' => true,
            'min_age_years' => 18,
            'holder' => ['full_name' => $user->display_name],
        ],
    ];

    $profiles['lopivi'] = [
        'expect' => 'es_sexual_offences_certificate',
        'checks' => [
            'max_age_days' => 90,
            'holder' => ['full_name' => $user->display_name],
        ],
    ];

    $profiles['receipt'] = [
        'expect' => 'payment_receipt',
        'checks' => [
            'expected_amount' => 45,
            'expected_iban' => 'ES7921000813610123456789',
            'expected_reference' => 'INSCRIPCION ' . $user->ID,
        ],
    ];

    return $profiles;
});

Uso: [constaia_upload profile="lopivi"] o [constaia_upload profile="receipt"]. Más sobre estos casos en Certificado de delitos sexuales y Justificantes de pago.

El certificado de delitos de naturaleza sexual incluye un código seguro de verificación (CSV). Constaia comprueba el formato del código (csv_format), pero no consulta el servicio de verificación del Ministerio: si lo necesitas, verifica el CSV en la sede oficial del emisor.

Guardar el resultado con constaia_analysis

wp-content/mu-plugins/constaia-save.php
<?php

add_action('constaia_analysis', static function (Constaia\Analysis $analysis, int $userId): void {
    update_user_meta($userId, 'constaia_analysis_id', $analysis->id);
    update_user_meta($userId, 'constaia_verdict', $analysis->verdictStatus() ?? $analysis->status);

    if ($analysis->needsReview()) {
        wp_mail(get_option('admin_email'), 'Documento pendiente de revisión', "Análisis {$analysis->id} del usuario {$userId}.");
    }
}, 10, 2);

verdictStatus() devuelve valid, invalid o review; si el análisis tarda más de 30 s la API responde 202, el estado es queued o processing y el resultado llega por webhook. Qué hacer con cada estado en Revisión humana.

Añadir un webhook

El plugin de ejemplo no recibe webhooks. Si usas 'async' => true en algún perfil, lotes o documentos largos, añade una ruta REST pública que verifique la firma con el cuerpo crudo ($request->get_body()). Este código es tuyo, no forma parte del plugin:

wp-content/mu-plugins/constaia-webhook.php
<?php

use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;

add_action('rest_api_init', static function (): void {
    register_rest_route('constaia/v1', '/webhook', [
        'methods' => 'POST',
        'permission_callback' => '__return_true', // la firma es la autenticación
        'callback' => static function (WP_REST_Request $request) {
            try {
                $event = Webhook::verify($request->get_body(), $request->get_headers(), CONSTAIA_WEBHOOK_SECRET);
            } catch (SignatureVerificationException $e) {
                return new WP_REST_Response(['error' => 'invalid signature'], 400);
            }

            $webhookId = (string) $request->get_header('webhook-id');
            if (get_transient('constaia_wh_' . md5($webhookId))) {
                return new WP_REST_Response(null, 204);
            }
            set_transient('constaia_wh_' . md5($webhookId), 1, 4 * DAY_IN_SECONDS);

            if (in_array($event['type'], ['analysis.completed', 'analysis.review_required', 'analysis.failed'], true)) {
                $analysis = $event['data'];
                $userId = (int) ($analysis['metadata']['wp_user_id'] ?? 0);
                if ($userId > 0) {
                    update_user_meta($userId, 'constaia_verdict', $analysis['verdict']['status'] ?? $analysis['status']);
                }
            }

            return new WP_REST_Response(null, 204);
        },
    ]);
});

Webhook::verify() acepta el formato de get_headers() de WordPress (claves en minúscula con guiones bajos y valores en array). Crea el endpoint en el panel con la URL https://tu-sitio.com/wp-json/constaia/v1/webhook y guarda el secret en CONSTAIA_WEBHOOK_SECRET. Formato y reintentos en Webhooks.

vendor/autoload.php ya lo carga el plugin; si pones este código en mu-plugins, que se carga antes, las clases del SDK están disponibles cuando se ejecuta rest_api_init.

Errores

El plugin captura Constaia\Exception\ConstaiaException (la base de todas las excepciones del SDK), registra el mensaje y el requestId con error_log() y devuelve 502 con un mensaje genérico. Si quieres mensajes distintos según el caso, captura antes las excepciones concretas:

ExcepciónCuándoMensaje sugerido
InvalidRequestException400, 409, 413, 415, 422 (errorCode: file_too_large, unsupported_file_type, unreadable_image…)Pide otro fichero
InsufficientCreditsException402Avisa al administrador; al usuario, "inténtalo más tarde"
RateLimitException429 tras los reintentos del SDK"Inténtalo en unos minutos"
AuthenticationException401Revisa CONSTAIA_API_KEY en wp-config.php
ApiException, ConnectionException5xx, red"Inténtalo más tarde"

Códigos completos en Errores.

Probar en modo test

Con define('CONSTAIA_API_KEY', 'ck_test_...') en un entorno de pruebas, inicia sesión, abre la página con el shortcode y sube ficheros reales (JPEG, PNG o PDF) con estos nombres. La tabla corresponde a los perfiles por defecto del plugin; si añades la comprobación holder del filtro, el nombre del usuario debe coincidir con el del documento de prueba (María García López en dni_valid.jpg).

FicheroPerfilResultado
dni_valid.jpgdnivalid: "Vigente hasta el 12/03/2031."
dni_expired.jpgdniinvalid: "Caducado el 15/06/2020."
blurry.jpgdnireview: aviso low_quality
medical_certificate.pdfmedicalvalid: firmado, apto

El widget muestra el veredicto y los motivos. Todos los ficheros en Modo test.

Checklist de producción

  • php.ini o panel del hosting: upload_max_filesize = 20M, post_max_size = 21M, max_execution_time = 90.
  • nginx client_max_body_size 21M; y fastcgi_read_timeout 90s; (o el equivalente de tu hosting); timeouts ≥ 60 s en proxy y CDN delante de /wp-json/.
  • Solo usuarios con sesión pueden subir (lo hace el plugin) y conviene limitar la frecuencia en /wp-json/constaia/v1/analyze (por ejemplo limit_req en nginx o tu WAF): cada análisis live consume créditos.
  • CONSTAIA_API_KEY con ck_live_ solo en producción; nunca en la base de datos de opciones ni en el tema.
  • Perfiles definidos en el servidor; no añadas atributos del shortcode que pasen expect o checks desde el contenido.
  • Webhook con firma verificada y deduplicado por webhook-id si usas procesamiento asíncrono.
  • Revisa Almacenamiento y privacidad; el plugin usa storage: none.

Siguientes pasos

En esta página