Plugin-Entwicklung für AliothPress
AliothPress unterstützt installierbare Plugins: eigenständige Ordner mit Python-Code, die das CMS um neue Admin‑Seiten, öffentliche Routen, Datenbanktabellen und Übersetzungen erweitern. Plugins können als separate Produkte mit eigenen Lizenzschlüsseln verkauft oder für die kostenlose oder private Nutzung entwickelt werden.
Die Grundregeln
- Eine gültige Core‑Lizenz ist erforderlich. Plugins werden nur auf lizenzierten AliothPress‑Installationen geladen. Ohne Core‑Lizenz funktioniert zwar die Plugins‑Seite, aber es wird kein Plugin geladen.
- Ein Plugin kann eine eigene Lizenz mitbringen.
"license_required": trueund eine"product_id"im Manifest setzen. Der Seitenbetreiber gibt dann auf der Plugins‑Seite einen Plugin‑Lizenzschlüssel ein; dieser wird gegen den AliothPress‑Update‑Server validiert (der Gumroad / LemonSqueezy / Envato als Proxy nutzt), mit demSECRET_KEYder Site verschlüsselt und mit einer HMAC‑Signatur gegen Manipulation gesichert – dasselbe Verfahren wie bei der Core‑Lizenz. - Kostenlose Plugins und Plugins von Drittanbietern sind willkommen. Bei
"license_required": falsewird kein Plugin‑Schlüssel benötigt. - Ein Neustart ist erforderlich, damit Änderungen wirksam werden. Python kann Code nicht sicher entladen, daher werden Installation, Aktivierung, Deaktivierung und Löschung erst nach einem Neustart der Anwendung übernommen. Das CMS plant diesen Neustart automatisch; eine Schaltfläche „Jetzt neu starten" für den Neustart per Klick erscheint nur dort als Alternative, wo sich der Server nicht selbst neu starten kann (zum Beispiel ein lokaler Entwicklungsserver).
- Installierter Code ist vertrauenswürdiger Code. Ein Plugin läuft mit denselben Berechtigungen wie das CMS selbst – es gibt keine Sandbox. Aus diesem Grund ist die Installation nur dem Besitzer vorbehalten, und Uploads werden vor dem Entpacken geprüft (Zip‑Slip‑Prüfungen, Größenbegrenzungen). Der Admin weist darauf hin, dass Plugins nur aus vertrauenswürdigen Quellen installiert werden sollten.
Aufbau eines Plugins
my_plugin/
plugin.json erforderlich – Manifest
__init__.py erforderlich – muss register(ctx) bereitstellen
icon.png optional – 64×64 Icon, angezeigt auf der Plugin‑Karte
unter Admin → Plugins
i18n/ optional – en.json, de.json, ... + public.json
templates/ optional – über das Blueprint registrieren
static/ optional – über das Blueprint registrieren
... beliebige weitere Python‑Module, die das Plugin benötigt
Das Plugin als ZIP dieser Ordnerstruktur verteilen (entweder den Ordner selbst als obersten Eintrag oder plugin.json im ZIP‑Stammverzeichnis).
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}$, muss dem Ordnernamen entsprechen. Er dient als Namensraum für Einstellungen, Settings‑Schlüssel und (konventionsgemäß) Tabellen.description— wird auf der Plugins‑Seite angezeigt. Zur Übersetzung den i18n‑Schlüssel<id>.descriptionzu den Sprachdateien des Plugins hinzufügen; bei aktivem Plugin wird der übersetzte Text verwendet, der Manifest‑String dient als Fallback (gleiche Konvention wie bei Theme‑Beschreibungen).min_cms_version— WIRD DURCHGESETZT: Ist die Installation älter, wird das Plugin nicht geladen und es erscheint eine klare Fehlermeldung mit der Aufforderung, das CMS zu aktualisieren.license_required+product_id– für kommerziell lizenzierte Plugins.product_idwird an den Update‑Server gesendet, damit dieser erkennt, welches Marktplatz‑Produkt zur Validierung des Schlüssels herangezogen werden muss.
__init__.py
def register(ctx): # erforderlich – wird einmal beim Start aufgerufen
...
def migrate(ctx): # optional – läuft vor register(), idempotent halten
...
Die ctx‑API
| Member | Zweck |
|---|---|
ctx.app |
Die Flask‑Anwendung |
ctx.db |
Die gemeinsame SQLAlchemy‑Instanz – Modelle mit ctx.db.Model definieren; neue Tabellen werden nach dem Laden des Plugins automatisch angelegt |
ctx.plugin_id, ctx.plugin_dir |
Identität und absoluter Pfad des Plugins |
ctx.register_blueprint(bp) |
Routen hinzufügen (Admin oder öffentlich) |
ctx.register_admin_nav(label_key, endpoint, icon) |
Seitenleisteneintrag im Bereich „Plugins“; label_key ist ein i18n‑Schlüssel |
ctx.register_i18n_dir(path) |
Übersetzungen in die Admin‑ und öffentliche i18n einbinden |
ctx.settings_get(key, default) / ctx.settings_set(key, value) |
Einstellungen mit Namensraum (plugin_<id>_<key> in der Settings‑Tabelle) |
ctx.license_plan() |
'standard' / 'extended' / '' für lizenzierte Plugins |
Reservierte Einstellungsnamen. Der Core führt seine eigene Buchhaltung im selben Namensraum plugin_<id>_..., daher können diese sechs Namen nicht als eigene Einstellungsschlüssel verwendet werden: enabled, license_key_encrypted, license_last_checked, license_plan, license_sig, license_valid. Sie enthalten den Aktivierungszustand des Plugins und die Lizenzdaten – insbesondere das Überschreiben von enabled deaktiviert das gesamte Plugin beim nächsten Neustart. ctx.settings_set() weist diese Namen mit einem ValueError zurück, sodass eine Kollision bereits während der Entwicklung deutlich fehlschlägt, statt eine laufende Website zu beschädigen. Besser einen anderen Namen wählen (zum Beispiel tracking statt enabled); der Lizenzplan lässt sich über ctx.license_plan() auslesen.
Core‑Dienste, ohne den Core zu importieren
Zwei Core‑Module können aus Plugin‑Code sicher importiert werden: models (Settings, Page, Post, User, AVAILABLE_LANGUAGES, ...) und i18n (t, t_for, pt, pt_for). Alles andere steht über ctx.app zur Verfügung:
| Benötigt | Bezug über |
|---|---|
| CSRF‑Objekt | ctx.app.extensions['csrf'] |
SECRET_KEY (z. B. um das gespeicherte SMTP‑Passwort genauso zu entschlüsseln wie der Core) |
ctx.app.config['SECRET_KEY'] |
| Logging | ctx.app.logger |
| Request‑ und Response‑Hooks | @ctx.app.after_request, usw. |
Auf keinen Fall app aus einem Plugin importieren. Wird das CMS als python app.py gestartet, läuft diese Datei als __main__ – der Import von app führt die gesamte Datei ein zweites Mal als neues Modul aus: Es entsteht eine zweite Flask‑App, und weil der Zustand des Plugin‑Laders geteilt wird, werden nachfolgende Plugins in die falsche App‑Instanz registriert. Das Symptom ist verwirrend und weit von der Ursache entfernt: ein BuildError für den Endpunkt eines anderen Plugins auf jeder Admin‑Seite. Unter gunicorn (app:app) funktioniert derselbe Import zufällig, was den Fehler leicht übersehbar macht.
Öffentliche Endpunkte für POST‑Anfragen von Besuchern
Der globale CSRFProtect blockiert jede POST‑Anfrage ohne Token. Eine Besucher‑API (z. B. ein Bewertungsformular oder ein Vote‑Button) hat keine Sitzung, um einen Token zu übertragen. Die betreffende Funktion vom CSRF befreien und mit dem Core‑Forms‑Muster kompensieren (Honeypot‑Feld + IP‑basierte Ratenbegrenzung, abgesichert durch eine Zeitstempelabfrage auf einer eigenen Tabelle):
@public_bp.route('/api/submit', methods=['POST'])
def submit():
...
csrf = ctx.app.extensions.get('csrf')
if csrf is not None:
csrf.exempt(submit)
Injektion in öffentliche Seiten
Die Plugin‑API bietet keine Template‑Hooks – dies ist absichtlich so: Hooks in Templates würden bei Theme‑Aktualisierungen brechen. Das bewährte Muster (verwendet vom Pop‑up‑Manager und Review‑Manager) ist die Nachbearbeitung des ausgehenden HTML:
@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
# ... Pfade überspringen, dann response.get_data(as_text=True),
# modifizieren, response.set_data(html)
except Exception:
ctx.app.logger.exception('my_plugin: injection failed')
return response
Regeln für einen sicheren Einsatz:
- Den gesamten Handler in einen try‑except‑Block fassen – ein defektes Plugin darf nicht mehr bewirken als „tut nichts“, niemals darf es „jede Seite zerstören“.
- Alles überspringen, was nicht zum Plugin gehört:
/admin,/api,/static,/setup,/mediaund das eigene Blueprint‑Präfix (sonst wird in die eigenen Admin‑Seiten injiziert). - Vorschauen sind öffentliche Templates unter einem Admin‑Pfad.
/admin/pages/<id>/previewund/admin/posts/<id>/previewrendern dieselben Templates, die Besucher sehen. Overlays (Pop‑ups) sollten in Vorschauen nicht erscheinen; Content‑Widgets (Bewertungen) sollten dort gerendert werden – andernfalls sieht der Betreiber eine Vorschau eines Entwurfs, vermisst das Feature und veröffentlicht ihn nie. Explizit^/admin/(pages|posts)/(\d+)/preview$abgleichen. - Platzierungsanker: Für ein Widget innerhalb des Inhaltsflusses entweder einen Marker ersetzen, den der Betreiber selbst in den Inhalt setzt (z. B.
[mein-marker], abgeglichen mit und ohne umschließendes<p>), oder vor dem schließenden HTML‑Tag von<article class="ap-page ...">einfügen – das gemeinsame öffentliche Template jedes Themes (die Themes bestehen nur aus CSS), sodass die Position über alle Themes stabil ist. Fallback: vor</body>. - Overlay‑Assets (schwebende Elemente, die nicht im Inhaltsfluss liegen) werden vor
</body>angehängt – siehe Pop‑up‑Manager.
Mehrsprachige Plugins
Zwei verschiedene Anforderungen, zwei verschiedene Muster:
- Vom Betreiber erstellte Inhalte (Pop‑ups, Formulare): Das Core‑Rezept verwenden – Spalten
translation_groupundlanguage, eine Zeile pro Sprache, und die richtige Version überg.current_languagebei öffentlichen Anfragen auswählen. Gruppenweit gültige Einstellungen leben in jeder Zeile; diese beim Speichern synchronisieren. - Von Besuchern erstellte Inhalte (Bewertungen, Kommentare): Der Text eines Besuchers kann nicht in eine Übersetzungsgruppe überführt werden. Stattdessen jeden Datensatz mit der Sprache der Seite versehen, von der aus er eingereicht wurde (
g.current_language), und die öffentliche Anzeige nach der Sprache der gerade gelesenen Seite filtern. Die Sprache wird ausschließlich durch die Herkunftsseite bestimmt, nicht durch den Textinhalt. Aggregate (Zählungen, Durchschnittsbewertungen) sollten in der Regel alle Sprachen umfassen – eine Bewertung hängt nicht von der Sprache ab, in der sie verfasst wurde.
Zeichenketten: Admin‑UI über t('my_plugin.key') / t_for(key, lang) aus den i18n/<lang>.json‑Dateien; besucherseitige Zeichenketten über pt('key') / pt_for(key, lang) aus i18n/public.json. Den gleichen Schlüsselsatz in allen Sprachdateien beibehalten – eine einfache Prüfung im Build‑Skript (z. B. 5 Zeilen) zahlt sich aus. Daten: Die Core‑Filter format_date / format_datetime verwenden – lokalisierte Muster für alle 31 Sprachen inklusive RTL sind im Core enthalten; für besucherseitige Daten ist auch ein neutrales YYYY‑MM‑DD eine gute Wahl.
Konventionen für gute Plugin‑Bürger
- Tabellen mit Präfix versehen:
plugin_<id>_something.db.create_all()ist global; Namen ohne Präfix können mit dem Core oder anderen Plugins kollidieren. - i18n‑Schlüssel unter der Plugin‑ID benennen:
{ "my_plugin": { "nav_label": "..." } }→t('my_plugin.nav_label'). Core‑Schlüssel gewinnen bei Kollision immer, daher kann kein Core‑UI‑Text überschrieben werden. Fehlende Sprachen fallen automatisch auf Englisch zurück – mindestensen.jsonmitliefern. - Öffentliche Zeichenketten gehören in
i18n/public.jsonim Core‑Format ({ "key": { "en": "...", "de": "..." } }) und werden mitpt()gelesen. - Spaltenmigrationen liegen in der Verantwortung des Plugin‑Entwicklers.
db.create_all()erstellt nur fehlende Tabellen. Wenn v2 eines Plugins eine Spalte hinzufügt, dasALTER TABLEinmigrate(ctx)idempotent durchführen (zuerst prüfen, dann ändern) – dasselbe Muster wiemigrate.pyim Core. Eine erprobte Form:
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 # Frischinstallation – db.create_all() erstellt das vollständige Schema
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'))
- Nur nullable Spalten –
ADD COLUMNmit derselben Anweisung funktioniert auf SQLite, PostgreSQL und MySQL. Für eine DatumsspalteTIMESTAMPbeidb.engine.dialect.name == 'postgresql'undDATETIMEsonst verwenden. - CSRF: Alle POST‑Routen sind durch den globalen CSRFProtect geschützt.
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">in die Formulare einfügen. - Authentifizierung:
flask_login(@login_required,current_user) und die Rollen‑Helfer (current_user.is_owner(),.can_access_settings()) verwenden. - Dunkel‑ / Hellmodus und 15 Themes: Die öffentliche Ausgabe auf den Theme‑CSS‑Variablen aufbauen (
--ap-color-*,--ap-space-*,--ap-radius), niemals auf einer eigenen fest codierten Palette, und logische Eigenschaften bevorzugen (margin-inline-start,border-inline-start,text-align: start), damit RTL‑Sprachen automatisch gespiegelt werden. Semantische Klassen allein stylen nichts: Ein themengestylter öffentlicher Button benötigt den vollständigen Core‑Satz –ap-btn ap-btn--primary ap-btn--md(plus eine eigene Klasse). Die Wiederverwendung der Core‑Klassen.ap-form__*gibt Formularen das genaue Aussehen des Form‑Builders. Im Admin die vorhandenen Klassen (settings-section,btn btn-primary,form-group,badge-*,help-text) nutzen und für Text auf primären oder gefährlichen Hintergründenvar(--adm-on-primary)/var(--adm-on-error)verwenden – diese Tokens sind in beiden Admin‑Modi WCAG‑AA‑konform, fest codierte Weißtöne sind es nicht. - Besuchertexte sind als potenziell gefährlicher User‑Input zu behandeln. Sie als Klartext speichern und bei jeder Ausgabe escapen – auch innerhalb von JSON‑LD: Eine Bewertung, die
</script>enthält, bricht aus einem<script type="application/ld+json">‑Block aus, es sei denn,json.dumps(data, ensure_ascii=False).replace('</', '<\\/')wird ausgegeben.
Lebenszyklus – Übersicht
- Der Betreiber lädt ein ZIP unter Admin → Plugins hoch (oder legt den Ordner manuell in
plugins/ab). - Das Plugin wird als Deaktiviert angezeigt. Der Betreiber aktiviert es (und aktiviert gegebenenfalls den Lizenzschlüssel, falls das Manifest dies verlangt).
- Das CMS plant automatisch einen sanften Neustart (als Fallback erscheint ein Button Jetzt neu starten, falls automatische Neustarts auf dem Host nicht verfügbar sind).
- Beim Start: Core‑Lizenz geprüft → aktivierte und lizenzierte Plugins werden importiert →
migrate(ctx)→register(ctx)→db.create_all(). - Core‑Updates berühren niemals den
plugins/‑Ordner (er steht auf der geschützten Pfadliste des Updaters), daher überleben Plugins CMS‑Updates. Plugin‑Updates: Eine Version auf dem Update‑Server veröffentlichen (latest.json+ versioniertes ZIP), und das Admin‑Panel des Benutzers bietet ein Ein‑Klick‑Update mit geprüfter Prüfsumme und Changelog an – oder der Benutzer lädt manuell ein neues ZIP mit derselbenidhoch. Einstellungen und Lizenz bleiben in beiden Fällen erhalten. - Katalog. Jedes Plugin, das auf dem Update‑Server veröffentlicht wird, erscheint auch im Block „Verfügbare Plugins“ unter Admin → Plugins, wo Benutzer es mit einem Klick installieren können (dieselbe prüfsummen‑gesicherte Pipeline wie bei Updates). Die Katalogkarte wird aus optionalen Feldern von
latest.jsonerstellt –"name"(Anzeigename; fällt auf dieidzurück) und"description"– sowie einericon.png, die nebenlatest.jsoninreleases/plugins/<id>/abgelegt wird. Alle drei sind optional und vollständig abwärtskompatibel.
Ein Plugin verkaufen
Es gibt zwei Wege, beide werden unterstützt:
Unabhängig – ohne Server‑Beteiligung. Ein Plugin wird als einfaches ZIP ausgeliefert: Es kann über jeden beliebigen Kanal verkauft werden, und Käufer installieren es über Aus ZIP installieren auf ihrer Plugins‑Seite ("license_required": false belassen – das eingebaute Schlüsselfeld ist für diesen Weg nicht gedacht). Wenn eine Schlüsselprüfung gewünscht ist, diese im eigenen Plugin implementieren: ein Schlüsselfeld auf der Plugin‑Einstellungsseite, das im eigenen Code gegen die Lizenz‑API des Marktplatzes validiert wird. Plugins laufen mit vollständigen Berechtigungen, daher steht dem Core nichts im Weg. Updates auf diesem Weg sind neue ZIPs, die an Käufer versendet werden; das Hochladen einer neueren Version derselben id ersetzt die Dateien und behält Einstellungen und Daten.
Über den offiziellen Katalog – nach Vereinbarung. Plugins, die auf den Update‑Server der Distribution aufgenommen werden, erhalten die eingebaute Mechanik: das Lizenzfeld auf der Plugin‑Karte, serverseitig validiert gegen das Gumroad‑ / LemonSqueezy‑ / Envato‑Produkt über die PLUGIN_PRODUCTS‑Zuordnung des Servers (der Verkauf selbst findet auf dem eigenen Marktplatzkonto statt – der Server validiert nur Schlüssel), Ein‑Klick‑Updates mit geprüfter Prüfsumme und Changelog sowie eine Karte im Katalog Verfügbare Plugins jeder Installation, die auf diesen Server zeigt. Für diesen Weg "license_required": true und die vereinbarte "product_id" im Manifest setzen, und latest.json + ein versioniertes ZIP pro Release bereitstellen ("name", "description" und eine icon.png neben latest.json hinzufügen, damit die Katalogkarte ansprechend aussieht). Kontakt mit dem Betreiber der Distribution aufnehmen, um ein Produkt zuordnen zu lassen – die Aufnahme liegt in dessen Ermessen.
Eine Installation verfügt über GENAU EINEN Update‑Server, der in ihrer eigenen Konfiguration festgelegt ist – Plugins können keinen eigenen mitbringen. Daher decken der Katalog und das eingebaute Lizenzfeld nur das ab, was der Betreiber dieses Servers veröffentlicht oder zugeordnet hat. Alles andere lebt auf dem oben beschriebenen unabhängigen Weg.