Desarrollo de plugins para AliothPress

· Plugins

AliothPress admite plugins instalables: carpetas autónomas con código Python que amplían el CMS con nuevas páginas de administración, rutas públicas, tablas de base de datos y traducciones. Los plugins se pueden comercializar como productos independientes con sus propias claves de licencia, o desarrollarse para uso gratuito o personal.

Las reglas básicas

  1. Se requiere una licencia del core válida. Los plugins solo se activan en instalaciones de AliothPress con licencia. Sin una licencia del core, la página Plugins funciona, pero no se carga ningún plugin.
  2. Un plugin puede tener su propia licencia. Se debe establecer "license_required": true y un "product_id" en el manifiesto. El propietario del sitio introduce entonces una clave de licencia del plugin en la página Plugins; esta se valida contra el servidor de actualizaciones de AliothPress (que actúa como proxy de Gumroad / LemonSqueezy / Envato), se cifra con el SECRET_KEY del sitio y se protege contra manipulaciones con una firma HMAC – el mismo mecanismo que utiliza la licencia del core.
  3. Los plugins gratuitos y de terceros son bienvenidos. Con "license_required": false, no se necesita ninguna clave de plugin.
  4. Es necesario reiniciar para que los cambios se apliquen. Python no puede liberar de forma segura el código importado, por lo que la instalación, activación, desactivación y eliminación surten efecto después de un reinicio de la aplicación. El CMS programa ese reinicio automáticamente; un botón «Reiniciar ahora» de un clic aparece como alternativa solo cuando el servidor no puede reiniciarse por sí mismo (por ejemplo, un servidor de desarrollo local).
  5. El código instalado es código de confianza. Un plugin se ejecuta con los mismos permisos que el propio CMS – no existe una sandbox. Por eso la instalación es exclusiva del propietario y las cargas se validan antes de descomprimir (comprobaciones de zip-slip, límites de tamaño). El administrador advierte que solo se deben instalar plugins desde fuentes de confianza.

Estructura de un plugin

my_plugin/
  plugin.json        requerido – manifiesto
  __init__.py        requerido – debe exponer register(ctx)
  icon.png           opcional – icono de 64×64, se muestra en la tarjeta del plugin
                     en Admin → Plugins
  i18n/              opcional – en.json, de.json, ... + public.json
  templates/         opcional – registrar a través del Blueprint
  static/            opcional – registrar a través del Blueprint
  ... cualquier otro módulo Python que necesite el plugin

El plugin se distribuye como un ZIP de esta estructura (ya sea la carpeta misma como único elemento de nivel superior, o plugin.json en la raíz del ZIP).

plugin.json

{
  "id": "my_plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "description": "What it does",
  "author": "Jane Doe",
  "author_url": "https://example.com",
  "license_required": false,
  "product_id": "",
  "min_cms_version": "2.3.0"
}
  • id^[a-z0-9_]{2,50}$, debe coincidir con el nombre de la carpeta. Sirve como espacio de nombres para las configuraciones, las claves de Settings y (por convención) las tablas.
  • description — se muestra en la página Plugins. Para traducirla, se debe añadir la clave i18n <id>.description a los archivos de idioma del plugin; cuando el plugin esté activo, se usará el texto traducido y la cadena del manifiesto servirá como alternativa (misma convención que en las descripciones de temas).
  • min_cms_version — SE APLICA OBLIGATORIAMENTE: si la instalación es más antigua, el plugin no se carga y se muestra un error claro que indica que se debe actualizar el CMS.
  • license_required + product_id — para plugins con licencia comercial. product_id se envía al servidor de actualizaciones para que sepa qué producto del mercado debe validar la clave.

__init__.py

def register(ctx):     # requerido – se llama una vez al iniciar
    ...

def migrate(ctx):      # opcional – se ejecuta antes de register(), mantener idempotente
    ...

La API ctx

Miembro Propósito
ctx.app La aplicación Flask
ctx.db La instancia compartida de SQLAlchemy — los modelos se definen con ctx.db.Model; las nuevas tablas se crean automáticamente después de cargar el plugin
ctx.plugin_id, ctx.plugin_dir Identidad y ruta absoluta del plugin
ctx.register_blueprint(bp) Añade rutas (admin o públicas)
ctx.register_admin_nav(label_key, endpoint, icon) Entrada en la barra lateral en la sección "Plugins"; label_key es una clave i18n
ctx.register_i18n_dir(path) Fusiona las traducciones en el i18n de admin y público
ctx.settings_get(key, default) / ctx.settings_set(key, value) Configuraciones con espacio de nombres (plugin_<id>_<key> en la tabla settings)
ctx.license_plan() 'standard' / 'extended' / '' para plugins con licencia

Nombres de ajustes reservados. El core guarda su propia contabilidad en el mismo espacio de nombres plugin_<id>_..., por lo que estos seis nombres no pueden usarse como claves de ajustes propios: enabled, license_key_encrypted, license_last_checked, license_plan, license_sig, license_valid. Contienen el estado de activación del plugin y los datos de la licencia – sobrescribir enabled, en particular, desactiva el plugin entero en el siguiente reinicio. ctx.settings_set() rechaza estos nombres con un ValueError, de modo que una colisión falla de forma visible durante el desarrollo en lugar de corromper un sitio en producción. Conviene elegir otro nombre (p. ej. tracking en vez de enabled); para leer el plan de la licencia se usa ctx.license_plan().

Servicios del core sin importar el core

Dos módulos del core se pueden importar de forma segura desde el código del plugin: models (Settings, Page, Post, User, AVAILABLE_LANGUAGES, ...) y i18n (t, t_for, pt, pt_for). Todo lo demás está disponible a través de ctx.app:

Necesita Origen
Objeto CSRF ctx.app.extensions['csrf']
SECRET_KEY (p. ej., para descifrar la contraseña SMTP almacenada de la misma manera que lo hace el core) ctx.app.config['SECRET_KEY']
Registro (logging) ctx.app.logger
Hooks de solicitud/respuesta @ctx.app.after_request, etc.

Nunca se debe importar app desde un plugin. Cuando el CMS se inicia como python app.py, ese archivo se ejecuta como __main__ — importar app ejecuta todo el archivo una segunda vez como un nuevo módulo: se construye una segunda aplicación Flask y, debido a que el estado del cargador de plugins es compartido, los plugins posteriores al suyo se registran en la instancia de aplicación incorrecta. El síntoma es confuso y está lejos de la causa: un BuildError para el endpoint de otro plugin en cada página de administración. Bajo gunicorn (app:app), la misma importación funciona por casualidad, lo que hace que el error sea fácil de pasar por alto.

Endpoints públicos para solicitudes POST de visitantes

El CSRFProtect global bloquea cualquier solicitud POST que no incluya un token. Una API orientada al visitante (por ejemplo, un formulario de reseñas o un botón de voto) no tiene sesión para transportar un token; se debe eximir esta función del CSRF y compensar con el método de Forms del core (campo honeypot + límite de tasa por IP respaldado por una consulta de marca de tiempo en su propia tabla):

@public_bp.route('/api/submit', methods=['POST'])
def submit():
    ...

csrf = ctx.app.extensions.get('csrf')
if csrf is not None:
    csrf.exempt(submit)

Inyección en páginas públicas

La API de plugins no ofrece hooks de plantillas – esto es intencional: los hooks en las plantillas se romperían con las actualizaciones de temas. El patrón probado (utilizado por Pop‑up Manager y Review Manager) es el posprocesamiento del HTML saliente:

@ctx.app.after_request
def _my_plugin_inject(response):
    try:
        if (response.status_code != 200 or response.direct_passthrough
                or 'text/html' not in (response.content_type or '')):
            return response
        # ... omitir rutas, luego response.get_data(as_text=True),
        #     modificar, response.set_data(html)
    except Exception:
        ctx.app.logger.exception('my_plugin: injection failed')
    return response

Reglas para un uso seguro:

  • Envolver todo el handler en un bloque try/except – un plugin defectuoso debe degradarse a "no hace nada", nunca a "rompe cada página".
  • Omitir lo que no pertenece al plugin: /admin, /api, /static, /setup, /media y el propio prefijo de Blueprint (de lo contrario, se inyectará en las propias páginas de administración).
  • Las previsualizaciones son plantillas públicas bajo una ruta de administración. /admin/pages/<id>/preview y /admin/posts/<id>/preview renderizan las mismas plantillas que ven los visitantes. Las superposiciones (pop‑ups) deben permanecer fuera de las previsualizaciones; los widgets de contenido (reseñas) deben renderizarse allí; de lo contrario, el propietario previsualiza un borrador, ve la función "ausente" y no llega a publicarlo. Se debe hacer una coincidencia explícita con ^/admin/(pages|posts)/(\d+)/preview$.
  • Anclas de colocación: para un widget dentro del flujo de contenido, se puede reemplazar un marcador que el propietario coloca en el contenido (p. ej., [mein-marker], coincidiendo con y sin un <p> envolvente), o insertar antes de la etiqueta de cierre de <article class="ap-page ..."> – la plantilla pública compartida de cada tema (los temas son solo CSS), por lo que la posición es estable en todos ellos. Como alternativa, antes de </body>.
  • Assets de superposición (elementos flotantes que no forman parte del flujo de contenido) se añaden antes de </body> – consulte Pop‑up Manager.

Plugins multilingües

Dos problemas diferentes, dos patrones diferentes:

  • Contenido creado por el propietario (pop‑ups, formularios): se debe utilizar el método del core – columnas translation_group y language, una fila por idioma, y seleccionar la versión correcta a partir de g.current_language en solicitudes públicas. La configuración a nivel de grupo vive en cada fila; se debe sincronizar al guardar.
  • Contenido creado por visitantes (reseñas, comentarios): el texto de un visitante no se puede "traducir a un grupo". En su lugar, se debe marcar cada registro con el idioma de la página desde la que se envió (g.current_language) y filtrar la visualización pública por el idioma de la página que se está leyendo. El idioma se determina exclusivamente por la página de origen, no por el contenido del texto. Los agregados (recuentos, calificaciones promedio) generalmente deben abarcar todos los idiomas – una calificación no depende del idioma en el que se escribió.

Cadenas: interfaz de administración mediante t('my_plugin.key') / t_for(key, lang) desde los archivos i18n/<lang>.json; cadenas orientadas al visitante mediante pt('key') / pt_for(key, lang) desde i18n/public.json. Se debe mantener el mismo conjunto de claves en todos los archivos de idioma – una comprobación simple en el script de compilación (por ejemplo, 5 líneas) vale la pena. Fechas: se deben reutilizar los filtros Jinja del core format_date / format_datetime – los patrones localizados para los 31 idiomas, incluido RTL, vienen con el core; para fechas orientadas al visitante, un formato neutral YYYY‑MM‑DD también es una opción válida.

Buenas prácticas para el desarrollo de plugins

  • Poner un prefijo a las tablas: plugin_<id>_something. db.create_all() es global; los nombres sin prefijo pueden colisionar con el core u otros plugins.
  • Usar un espacio de nombres para las claves i18n bajo el id del plugin: { "my_plugin": { "nav_label": "..." } }t('my_plugin.nav_label'). Las claves del core siempre ganan en caso de colisión, por lo que no se puede sobrescribir el texto de la interfaz de usuario del core. Los idiomas faltantes usan automáticamente el inglés por defecto – se debe incluir al menos en.json.
  • Las cadenas públicas van en i18n/public.json usando el formato del core ({ "key": { "en": "...", "de": "..." } }) y se leen con pt().
  • Las migraciones de columnas son responsabilidad del desarrollador. db.create_all() solo crea tablas faltantes. Si la v2 de un plugin añade una columna, se debe realizar el ALTER TABLE en migrate(ctx) de forma idempotente (primero verificar, luego modificar) – el mismo patrón que migrate.py del core. Una forma probada:
def migrate(ctx):
    from sqlalchemy import inspect as sa_inspect, text
    db, table = ctx.db, 'plugin_my_plugin_things'
    insp = sa_inspect(db.engine)
    if table not in insp.get_table_names():
        return  # instalación limpia – db.create_all() construye el esquema completo
    cols = {c['name'] for c in insp.get_columns(table)}
    with db.engine.begin() as conn:
        if 'new_col' not in cols:
            conn.execute(text(f'ALTER TABLE {table} ADD COLUMN new_col TEXT'))
  • Utilizar solo columnas nullable – ADD COLUMN con la misma instrucción funciona en SQLite, PostgreSQL y MySQL. Para una columna de fecha y hora, usar TIMESTAMP cuando db.engine.dialect.name == 'postgresql' y DATETIME en los demás casos.
  • CSRF: todas las rutas POST están protegidas por el CSRFProtect global. Se debe incluir <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> en los formularios.
  • Autenticación: usar flask_login (@login_required, current_user) y los ayudantes de roles (current_user.is_owner(), .can_access_settings()).
  • Modo oscuro/claro y 15 temas: el contenido público se debe generar usando las variables CSS del tema (--ap-color-*, --ap-space-*, --ap-radius), nunca con una paleta fija propia, y se deben preferir propiedades lógicas (margin-inline-start, border-inline-start, text-align: start) para que los idiomas RTL se reflejen automáticamente. Las clases semánticas por sí solas no estilizan nada: un botón público con estilo del tema necesita el conjunto completo del core – ap-btn ap-btn--primary ap-btn--md (más una clase propia). Reutilizar las clases del core .ap-form__* da a los formularios el aspecto exacto del Form Builder. En el administrador, se deben reutilizar las clases existentes (settings-section, btn btn-primary, form-group, badge-*, help-text) y usar var(--adm-on-primary) / var(--adm-on-error) para el texto colocado sobre fondos primarios o de peligro – estos tokens son correctos según WCAG‑AA en ambos modos de administración; los valores blancos fijos no lo son.
  • El texto del visitante es potencialmente peligroso. Se debe almacenar como texto plano, escaparlo en cada salida – incluso dentro de JSON‑LD: una reseña que contenga </script> se escapa de un bloque <script type="application/ld+json"> a menos que se emita json.dumps(data, ensure_ascii=False).replace('</', '<\\/').

Resumen del ciclo de vida

  1. El propietario carga un ZIP en Admin → Plugins (o coloca la carpeta manualmente en plugins/).
  2. El plugin se muestra como Desactivado. El propietario lo activa (y activa su clave de licencia si el manifiesto lo requiere).
  3. El CMS programa automáticamente un reinicio suave (aparece un botón Reiniciar ahora como alternativa cuando los reinicios automáticos no están disponibles en el servidor).
  4. Al iniciar: licencia del core verificada → los plugins habilitados y con licencia se importan → migrate(ctx)register(ctx)db.create_all().
  5. El core deja intacta la carpeta plugins/ (está en la lista de rutas protegidas del actualizador), por lo que los plugins sobreviven a las actualizaciones del CMS. Actualizaciones de plugins: se debe publicar una versión en el servidor de actualizaciones (latest.json + ZIP versionado), y el panel de administración del usuario ofrece una actualización con un clic, con suma de comprobación verificada y su registro de cambios; o el usuario carga manualmente un nuevo ZIP con la misma id. La configuración y la licencia se mantienen en cualquier caso.
  6. Catálogo. Cada plugin publicado en el servidor de actualizaciones también aparece en el bloque "Plugins disponibles" en Admin → Plugins, donde los usuarios lo instalan con un clic (el mismo flujo con suma de comprobación verificado que las actualizaciones). La tarjeta del catálogo se construye a partir de campos opcionales de latest.json"name" (nombre mostrado; usa el id como alternativa) y "description" – más un icon.png colocado junto a latest.json en releases/plugins/<id>/. Los tres son opcionales y completamente compatibles con versiones anteriores.

Vender un plugin

Existen dos vías, ambas compatibles:

De forma independiente – sin intervención del servidor. Un plugin se distribuye como un ZIP simple: se puede vender a través de cualquier canal, y los compradores lo instalan mediante Instalar desde ZIP en su página Plugins (se debe mantener "license_required": false – el campo de clave integrado no es para esta vía). Si se desea aplicar la clave, se debe implementar dentro del propio plugin: un campo de clave en la página de configuración del plugin, validado en el código contra la API de licencias del mercado. Los plugins se ejecutan con permisos completos, por lo que nada en el core se interpone. Las actualizaciones en esta vía son nuevos ZIP enviados a los compradores; cargar una versión más reciente del mismo id reemplaza los archivos y mantiene la configuración y los datos.

A través del catálogo oficial – mediante acuerdo. Los plugins aceptados en el servidor de actualizaciones de la distribución obtienen la maquinaria integrada: el campo de licencia en la tarjeta del plugin, validado del lado del servidor contra el producto de Gumroad / LemonSqueezy / Envato a través del mapeo PLUGIN_PRODUCTS del servidor (la venta en sí ocurre en la cuenta de mercado del desarrollador – el servidor solo valida claves), actualizaciones con un clic y suma de comprobación verificada con su registro de cambios, y una tarjeta en el catálogo de Plugins disponibles de cada instalación que apunte a ese servidor. Para esta vía, se debe establecer "license_required": true y el "product_id" acordado en el manifiesto, y proporcionar latest.json + un ZIP versionado por lanzamiento (añadir "name", "description" y un icon.png junto a latest.json para que la tarjeta del catálogo luzca bien). Se debe contactar con el propietario de la distribución para que un producto sea mapeado – la aceptación queda a su discreción.

Una instalación dispone de EXACTAMENTE UN servidor de actualizaciones, establecido en su propia configuración – los plugins no pueden traer el suyo propio, por lo que el catálogo y el campo de licencia integrado solo cubren lo que el propietario de ese servidor ha publicado o mapeado. Todo lo demás vive en la vía independiente descrita anteriormente.

Preguntas frecuentes

¿En qué lenguaje están escritos los plugins?
Python. Un plugin registra Flask Blueprints y puede usar modelos de SQLAlchemy a través del contexto proporcionado.
¿Mi plugin puede tener sus propias tablas de base de datos?
Sí. Se definen modelos con ctx.db.Model y las tablas se crean cuando se carga el plugin. Los nombres de las tablas deben llevar como prefijo el id del plugin para evitar colisiones.
¿Cómo se actualiza un plugin en un sitio en producción?
Se aumenta la versión en plugin.json y se entrega un nuevo ZIP. Al subirlo en la página Plugins, se reemplazan los archivos y se reinicia el plugin. Los plugins distribuidos a través de un servidor de actualizaciones reciben además actualizaciones con un clic: la nueva versión y el changelog aparecen directamente en la tarjeta del plugin del usuario.
¿Puedo vender mi plugin?
Sí, de dos formas. De forma independiente: se distribuye como un ZIP simple a través de cualquier canal. Los compradores lo instalan con Install from ZIP, y funciona en cualquier sitio AliothPress con licencia. Se debe mantener license_required en false; el campo de clave integrado no es para esta vía. Si se desea cobrar y aplicar una clave propia, se añade un campo de clave en la página de configuración del plugin y se valida en el código contra la API del mercado. Los plugins se ejecutan con permisos completos, por lo que nada en el núcleo se interpone. A través del catálogo oficial (mediante acuerdo): los plugins aceptados en el servidor de actualizaciones de la distribución obtienen la maquinaria integrada: el campo de licencia en la tarjeta del plugin (validado del lado del servidor contra el producto de Gumroad / LemonSqueezy / Envato), actualizaciones con un clic y suma de comprobación verificada con su changelog, y un listado en el catálogo de Plugins disponibles. Para esta vía, se debe establecer license_required en true con el product_id acordado, y el propietario del servidor asigna ese producto en su extremo (cada plugin con licencia se añade a PLUGIN_PRODUCTS del servidor). Se debe contactar con el propietario de la distribución para que un producto sea asignado; la aceptación queda a su discreción.
¿Tengo que traducir mi plugin a los 31 idiomas de administración?
No. Se entregan solo los idiomas que se puedan mantener; el resto cae al inglés por defecto.
¿Un plugin puede añadir algo al sitio público, no solo al administrador?
Sí. Un plugin registra rutas públicas como cualquier Flask Blueprint, y para colocar widgets dentro de páginas existentes existe un patrón probado de procesamiento de respuestas, que utilizan tanto el plugin de pop-ups como el de reseñas incluidos. PLUGIN_DEV.md lo documenta paso a paso.