Plugin-Entwicklung für AliothPress

· Plugins

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

  1. 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.
  2. Ein Plugin kann eine eigene Lizenz mitbringen. "license_required": true und 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 dem SECRET_KEY der Site verschlüsselt und mit einer HMAC‑Signatur gegen Manipulation gesichert – dasselbe Verfahren wie bei der Core‑Lizenz.
  3. Kostenlose Plugins und Plugins von Drittanbietern sind willkommen. Bei "license_required": false wird kein Plugin‑Schlüssel benötigt.
  4. 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).
  5. 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>.description zu 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_id wird 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, /media und das eigene Blueprint‑Präfix (sonst wird in die eigenen Admin‑Seiten injiziert).
  • Vorschauen sind öffentliche Templates unter einem Admin‑Pfad. /admin/pages/<id>/preview und /admin/posts/<id>/preview rendern 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_group und language, eine Zeile pro Sprache, und die richtige Version über g.current_language bei ö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 – mindestens en.json mitliefern.
  • Öffentliche Zeichenketten gehören in i18n/public.json im Core‑Format ({ "key": { "en": "...", "de": "..." } }) und werden mit pt() gelesen.
  • Spaltenmigrationen liegen in der Verantwortung des Plugin‑Entwicklers. db.create_all() erstellt nur fehlende Tabellen. Wenn v2 eines Plugins eine Spalte hinzufügt, das ALTER TABLE in migrate(ctx) idempotent durchführen (zuerst prüfen, dann ändern) – dasselbe Muster wie migrate.py im 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 COLUMN mit derselben Anweisung funktioniert auf SQLite, PostgreSQL und MySQL. Für eine Datumsspalte TIMESTAMP bei db.engine.dialect.name == 'postgresql' und DATETIME sonst 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ünden var(--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

  1. Der Betreiber lädt ein ZIP unter Admin → Plugins hoch (oder legt den Ordner manuell in plugins/ ab).
  2. Das Plugin wird als Deaktiviert angezeigt. Der Betreiber aktiviert es (und aktiviert gegebenenfalls den Lizenzschlüssel, falls das Manifest dies verlangt).
  3. 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).
  4. Beim Start: Core‑Lizenz geprüft → aktivierte und lizenzierte Plugins werden importiert → migrate(ctx)register(ctx)db.create_all().
  5. 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 derselben id hoch. Einstellungen und Lizenz bleiben in beiden Fällen erhalten.
  6. 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.json erstellt – "name" (Anzeigename; fällt auf die id zurück) und "description" – sowie einer icon.png, die neben latest.json in releases/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.

Häufig gestellte Fragen

In welcher Sprache werden Plugins geschrieben?
Python. Ein Plugin registriert Flask-Blueprints und kann SQLAlchemy-Modelle über den bereitgestellten Kontext verwenden.
Kann mein Plugin eigene Datenbanktabellen haben?
Ja. Modelle mit ctx.db.Model definieren, und die Tabellen werden beim Laden des Plugins erstellt. Tabellennamen mit der Plugin-ID als Präfix versehen, um Kollisionen zu vermeiden.
Wie wird ein Plugin auf einer Live-Site aktualisiert?
Die Version in plugin.json erhöhen und ein neues ZIP ausliefern. Das Hochladen auf der Plugins-Seite ersetzt die Dateien und startet das Plugin neu. Plugins, die über einen Update-Server vertrieben werden, erhalten zusätzlich Ein-Klick-Updates: Die neue Version und der Changelog erscheinen direkt auf der Plugin-Karte des Benutzers.
Kann ich mein Plugin verkaufen?
Ja, auf zwei Arten. Unabhängig: Es wird als einfaches ZIP über beliebige Kanäle vertrieben. Käufer installieren es mit Install from ZIP, und es läuft auf jeder lizenzierten AliothPress-Site. license_required auf false belassen; das eingebaute Schlüsselfeld ist nicht für diesen Weg gedacht. Wenn eine eigene Schlüsselprüfung gewünscht ist, ein Schlüsselfeld auf der eigenen Plugin-Einstellungsseite hinzufügen und im Code gegen die API des Marktplatzes validieren. Plugins laufen mit vollständigen Berechtigungen, daher steht dem Core nichts im Weg. Ü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), Ein-Klick-Updates mit geprüfter Prüfsumme und Changelog sowie eine Auflistung im Katalog Verfügbare Plugins. Für diesen Weg license_required auf true setzen mit der vereinbarten product_id, und der Server-Betreiber ordnet dieses Produkt auf seiner Seite zu (jedes lizenzierte Plugin wird zu PLUGIN_PRODUCTS des Servers hinzugefügt). Kontakt mit dem Distributionsbetreiber aufnehmen, um ein Produkt zuordnen zu lassen; die Aufnahme liegt in dessen Ermessen.
Muss ich mein Plugin in alle 31 Admin-Sprachen übersetzen?
Nein. Nur die Sprachen ausliefern, die gepflegt werden können; alles andere fällt auf Englisch zurück.
Kann ein Plugin auch etwas zur öffentlichen Site hinzufügen, nicht nur zum Admin?
Ja. Ein Plugin registriert öffentliche Routen wie jedes Flask-Blueprint, und für die Platzierung von Widgets innerhalb bestehender Seiten gibt es ein bewährtes Response-Processing-Muster, das sowohl die mitgelieferten Pop-up- als auch Review-Plugins verwenden. PLUGIN_DEV.md dokumentiert es Schritt für Schritt.
Wo finde ich ein funktionierendes Beispiel?
Jede Installation enthält das Hello World-Plugin und die PLUGIN_DEV.md-Referenz im Anwendungsordner.