„Over 60 million people have chosen WordPress to power the place on the web they call “home” — join the family.“
Zugegeben, die offizielle Seite von WordPress, offeriert nicht gerade, seine Funktionsweise zu verstehen. Allerdings wäre das hin und wieder ganz nützlich – vor allem wenn man es in einer Umgebung betreiben will, für die es offiziell nicht vorgesehen ist.
Plugin-Updates
Unser Usecase ist eigentlich nicht sonderlich umfangreich (dachten wir): wir wollen unabhängig von WordPress selbst, nach Updates für diverse Plugins Ausschau halten und diese ggfs. laden.
Klingt einfach – WordPress macht es ja auch irgendwie.
Der erste Fehler ist dann schon mal, nach „WordPress API“ zu suchen. Denn da findet sich so einiges – aber eben nur Dokumentation dazu, wie man die WordPress-Installation selbst via API anspricht. Wir wollen aber den Endpunkt ansprechen, von dem sich besagte Installation die Updates zieht. Das wäre dann „wordpress.org“. Und überraschender Weise gibt es tatsächlich eine rudimentäre Dokumentation dazu: https://codex.wordpress.org/WordPress.org_API#Plugins
seeeehr rudimentär…
Abgesehen von ein paar URLs steht da allerdings nicht viel.
Wie man die URLs aufruft? GET, POST, HEAD? Fehlanzeige.
Welche Parameter man mitgeben kann? Pustekuchen.
Wie der Request-Body für einen POST-Request aussehen muss? Das wäre ja noch schöner.
Also begeben wir uns mit den URLs bewaffnet auf die Suche im Internet. Und wir finden …genau: nichts brauchbares!
Alle Suchergebnise beschäftigen sich mit der Verwendung der APIs mittels PHP innerhalb von WordPress. Wie der nackte HTTP-Request aussehen muss, bleibt offen.
dann eben mit Gewalt…
Und uns bleibt nur der Brute-Force-Ansatz: die Methode wp_update_plugins
in der Datei wp-includes/update.php
so anpassen, dass sie gegen einen von uns betriebenen nginx geht, der den request_body mit loggt:
log_format wp_post_body $request_body; ... server { ... location /logsink { return 200; } location /plugins/ { access_log logs/wp-test.access.log wp_post_body; proxy_pass $scheme://127.0.0.1/logsink; break; } ... }
Der proxy_pass auf den lokalen „logsink“ ist notwendig, da nginx ansonsten keine Veranlassung sieht, den request_body zu lesen und dann nur einen dash („-„) loggen würde!
Damit sehen wir nun endlich wie der request_body grob aussehen muss:
plugins=PLUGIN_JSON&translations=TRANSLATIONS_JSON&locale=LOCALE_ARRAY&all=true
Für die Werte hab ich der Übersichtlichkeit halber hier erst einmal Platzhalter benutzt, damit die Struktur ersichtlich ist.
Die Werte müssen außerdem alle URL-Encoded sein – im folgenden werde ich aber nur die nicht encodeten Werte besprechen.
try and error
Fangen wir mal hinten an, weil der Teil am kürzesten ist:
locale=["de_DE"]
Ob da auch mehrere Locales stehen können, hab ich noch nicht herausfinden können. Zumal ich auch nicht sicher bin, ob das überhaupt wichtig ist.
Die beiden anderen Werte sind jeweils JSON-Konstrukte.
Plugins-JSON:
{ "plugins": { "classic-editor\/classic-editor.php": { "Name": "Classic+Editor", "PluginURI": "https:\/\/wordpress.org\/plugins\/classic-editor\/", "Version": "1.5", "Description": "Enables+the+WordPress+classic+editor+and+the+old-style+Edit+Post+screen+with+TinyMCE,+Meta+Boxes,+etc.+Supports+the+older+plugins+that+extend+this+screen.", "Author": "WordPress+Contributors", "AuthorURI": "https:\/\/github.com\/WordPress\/classic-editor\/", "TextDomain": "classic-editor", "DomainPath": "\/languages", "Network": false, "RequiresWP": "", "RequiresPHP": "", "Title": "Classic+Editor", "AuthorName": "WordPress+Contributors" }, "wordfence\/wordfence.php": { "Name": "Wordfence+Security", "PluginURI": "http:\/\/www.wordfence.com\/", "Version": "7.4.9", "Description": "Wordfence+Security+-+Anti-virus,+Firewall+and+Malware+Scan", "Author": "Wordfence", "AuthorURI": "http:\/\/www.wordfence.com\/", "TextDomain": "wordfence", "DomainPath": "", "Network": true, "RequiresWP": "", "RequiresPHP": "", "Title": "Wordfence+Security", "AuthorName": "Wordfence" }, "wordpress-seo\/wp-seo.php": { "Name": "Yoast+SEO", "PluginURI": "https:\/\/yoa.st\/1uj", "Version": "14.6.1", "Description": "The+first+true+all-in-one+SEO+solution+for+WordPress,+including+on-page+content+analysis,+XML+sitemaps+and+much+more.", "Author": "Team+Yoast", "AuthorURI": "https:\/\/yoa.st\/1uk", "TextDomain": "wordpress-seo", "DomainPath": "\/languages\/", "Network": false, "RequiresWP": "5.3", "RequiresPHP": "5.6.20", "Title": "Yoast+SEO", "AuthorName": "Team+Yoast" } }, "active": [ "classic-editor\/classic-editor.php", "wordfence\/wordfence.php", "wordpress-seo\/wp-seo.php" ] }
Translations-JSON:
{ "classic-editor": { "de_DE": { "POT-Creation-Date": "", "PO-Revision-Date": "2020-04-02+03:42:41+0000", "Project-Id-Version": "Plugins+-+Classic+Editor+-+Stable+(latest+release)", "X-Generator": "GlotPress\/2.4.0-alpha" } }, "wordpress-seo": { "de_DE": { "POT-Creation-Date": "", "PO-Revision-Date": "2020-07-28+07:28:37+0000", "Project-Id-Version": "Plugins+-+Yoast+SEO+-+Stable+(latest+release)", "X-Generator": "GlotPress\/3.0.0-alpha.2" } } }
Jetzt stellt sich natürlich die Frage, wo kommen die Daten her. In der Datenbank von WordPress stehen sie schon mal nicht.
Und die werden ja nicht so blöd sein, stumpf alle PHP-Files in den Plugin-Verzeichnissen durch zu gehen, zu parsen und zu schauen, ob zufällig die benötigten Informationen drinne stehen… Nun, äh …doch!
Genau das tut WordPress -.-
Minimalismus
Glücklicherweise benötigen wir nicht all die Daten, die WordPress von sich aus mit schickt.
Nach einigen Tests stellte sich heraus, dass die API auch mit einem minimalen Set an Daten eine hinreichende Antwort schickt:
{ "plugins": { "classic-editor/classic-editor.php": { "Version": "1.5" } } }
Ja, der Slash muss nicht zwingend escaped werden. Dafür dient die Pfadangabe des primären PHP-Files offenbar als Identifier. Diese muss also bekannt sein. Und nein: der Name der PHP-Datei stimmt nicht zwingend mit dem des Plugin überein!
(Wie wir bspw. an folgendem Plugin sehen können: wordpress-seo\/wp-seo.php
)
Diese Pfadangabe muss also initial einmal herausgefunden werden.
Die Translations benötigt/verlangt die API auch nicht zwingend – er liefert dann im Result die verfügbaren Translations für die angegebene Locale mit. Diese Angabe kann also im Zweifelsfalls also ebenfalls weg gelassen werden.
Wenn wir die Locale auch weg lassen, liefert er für die Translations ein leeres Array zurück. Damit kann also der request_body noch weiter minimiert werden.
Jetzt können wir das ganze schön URL-Encoden, in eine Datei (bspw. „wp-test-post-data“) packen und gegen die API schicken:
curl -X POST "https://api.wordpress.org/plugins/update-check/1.1/" -d @wp-test-post-data
Die Datei würde für den oben angebenen Schnipsel übrigens so aussehen:
plugins=%7B%0A%20%20%22plugins%22%3A%20%7B%0A%20%20%20%20%22classic-editor%2Fclassic-editor.php%22%3A%20%7B%0A%20%20%20%20%20%20%20%20%22Version%22%3A%20%221.5%22%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%7D%0A
Als Ergebnis erhalten wir dann ein JSON zurück:
{ "plugins": { "classic-editor\/classic-editor.php": { "id": "w.org\/plugins\/classic-editor", "slug": "classic-editor", "plugin": "classic-editor\/classic-editor.php", "new_version": "1.6", "url": "https:\/\/wordpress.org\/plugins\/classic-editor\/", "package": "https:\/\/downloads.wordpress.org\/plugin\/classic-editor.1.6.zip", "icons": { "2x": "https:\/\/ps.w.org\/classic-editor\/assets\/icon-256x256.png?rev=1998671", "1x": "https:\/\/ps.w.org\/classic-editor\/assets\/icon-128x128.png?rev=1998671" }, "banners": { "2x": "https:\/\/ps.w.org\/classic-editor\/assets\/banner-1544x500.png?rev=1998671", "1x": "https:\/\/ps.w.org\/classic-editor\/assets\/banner-772x250.png?rev=1998676" }, "banners_rtl": [], "tested": "5.5", "requires_php": "5.2.4", "compatibility": [] } }, "translations": [], "no_update": [] }
Wenn bereits die aktuelle Version angefragt wurde, würde der Eintrag entsprechend unter „no_update“ erscheinen!
Und damit haben wir endlich die Information die wir brauchen um mit den Plugin-Updates weiter zu kommen. Warum muss man sowas immer erst auf die harte Tour herausfinden?
Kommentare von Martin Drößler