Desarrollo de plugins para AliothPress
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
- 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.
- Un plugin puede tener su propia licencia. Se debe establecer
"license_required": truey 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 elSECRET_KEYdel sitio y se protege contra manipulaciones con una firma HMAC – el mismo mecanismo que utiliza la licencia del core. - Los plugins gratuitos y de terceros son bienvenidos. Con
"license_required": false, no se necesita ninguna clave de plugin. - 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).
- 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>.descriptiona 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_idse 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,/mediay 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>/previewy/admin/posts/<id>/previewrenderizan 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_groupylanguage, una fila por idioma, y seleccionar la versión correcta a partir deg.current_languageen 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 menosen.json. - Las cadenas públicas van en
i18n/public.jsonusando el formato del core ({ "key": { "en": "...", "de": "..." } }) y se leen conpt(). - 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 elALTER TABLEenmigrate(ctx)de forma idempotente (primero verificar, luego modificar) – el mismo patrón quemigrate.pydel 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 COLUMNcon la misma instrucción funciona en SQLite, PostgreSQL y MySQL. Para una columna de fecha y hora, usarTIMESTAMPcuandodb.engine.dialect.name == 'postgresql'yDATETIMEen 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 usarvar(--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 emitajson.dumps(data, ensure_ascii=False).replace('</', '<\\/').
Resumen del ciclo de vida
- El propietario carga un ZIP en Admin → Plugins (o coloca la carpeta manualmente en
plugins/). - El plugin se muestra como Desactivado. El propietario lo activa (y activa su clave de licencia si el manifiesto lo requiere).
- 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).
- Al iniciar: licencia del core verificada → los plugins habilitados y con licencia se importan →
migrate(ctx)→register(ctx)→db.create_all(). - 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 mismaid. La configuración y la licencia se mantienen en cualquier caso. - 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 unicon.pngcolocado junto alatest.jsonenreleases/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.