Eine Minimalimplementierung in JavaScript und Node.js

µCMS – ein minimalistisches CMS implementieren
Keine Kommentare

Mit der Etablierung von Node.js als Webtechnologie hat sich die Gestaltung von Webanwendungen grundlegend vereinfacht. Node.js ermöglicht es, in JavaScript einen vollwertigen Server zu implementieren und insbesondere die im Browser-JavaScript nicht verfügbaren Dateisystemzugriffe umzusetzen. In diesem Artikel implementieren wir ein sehr einfaches Content-Management-System ausschließlich auf Grundlage der Möglichkeiten von JavaScript/Node.js und der für Node.js zur Verfügung stehenden Bibliotheken.

Ein Content-Management-System (CMS) dient dazu, Webinhalte über ein Web-Frontend im Browser dynamisch zu ändern, d. h., es erlaubt entsprechend berechtigten Anwendern, Seiten bzw. Dateien hinzuzufügen, zu ändern und zu löschen. Damit erleichtern CMS die Wartung von Webinhalten enorm, da der Bearbeiter die meisten Schritte aus einem einzigen Werkzeug vornehmen kann und zur Änderung der Inhalte nicht in einen Editor für HTML-Dateien oder eine Programmierumgebung wechseln muss. Die Arbeitsweise gleicht eher dem Umgang mit einem Textverarbeitungsprogramm, bei dem Inhaltsänderungen direkt in der Applikation vorgenommen werden können. Dem Speichern in der Textverarbeitung entspricht bei Webinhalten dabei das Veröffentlichen der durchgeführten Änderungen. Ein weiterer Vorteil für die Administration entsprechender Systeme ist, dass dem Bearbeiter kein explizites Schreibrecht für das Dateisystem eingeräumt werden muss. Die Berechtigung zum Ändern von Inhalten wird vom CMS nicht auf Grundlage betriebssystemseitiger Schreibrechte, sondern auf Basis der Rolle des Anwenders vergeben. Ein Schreibrecht für das Dateisystem muss nur für den Anwender vergeben werden, unter dem der Prozess des CMS gestartet wird.

Es gibt viele existierende Implementierungen von CMS, z. B. TYPO3, Joomla! und unzählige andere mehr. Die Stärke dieser Implementierungen liegt darin, dass sie einen viel einfacheren Administrationszugang zu Webinhalten und -seiten bereitstellen, d. h. Seiten können nach dem WYSIWYG-Prinzip (What You See Is What You Get) bearbeitet werden. Darüber hinaus verfügen sie über ausgereifte Bibliotheken von Vorlagen/Templates und können damit und über Plug-in-Mechanismen an viele Anwendungssituationen angepasst werden. Der Einsatzbereich reicht von einfachen Vereinswebseiten bis zu kompletten Webshops.

Mit der Leistungsfähigkeit der genannten CMS geht eine gewisse Komplexität einher. Die meisten installierten CMS vereinen die folgenden Eigenschaften:

  • Der Zugriff auf die Daten erfolgt über einen dedizierten Webserver, meistens Apache.
  • Die Verwaltung der Inhalte geschieht in einem Datenbank-Management-System (DBMS), meistens MySQL.
  • Die serverseitige Logik wird meistens in einer Skriptsprache, in der Regel PHP, implementiert.

Das lässt sich gut mit dem weitgehend für Webanwendungen verbreiteten LAMP-Stack (Linux als Hostsystem, Apache als Webserver, MySQL als Datenbank und PHP als serverseitige Skriptsprache) abbilden. LAMP als bewährte Technik hat viele Vorteile, auf die hier nicht weiter eingegangen werden soll. Es gibt aber auch Nachteile:

  • Ein Webserver muss installiert, administriert und gewartet werden.
  • Ein DBMS muss installiert, administriert und gewartet werden.
  • Die serverseitig verwendete Programmiersprache (PHP) unterscheidet sich von der clientseitig verwendeten Sprache (JavaScript).

Insbesondere für den letzten Punkt waren lange Zeit keine Alternativen denkbar, da JavaScript primär nur als Browsersprache zur Verfügung stand und damit aus Sicherheitsgründen nur eingeschränkt nutzbar war. Eine der wichtigsten Einschränkungen war dabei der fehlende Zugriff auf das Dateisystem. Mit der Einführung von Node.js als Stand-Alone-Interpreter für JavaScript ist diese Einschränkung gefallen. Mit Node.js und den mittlerweile verfügbaren Bibliotheken ist es möglich, einen vollwertigen Webserver und die serverseitige Applikationslogik vollständig in JavaScript zu implementieren. Wir werden daher in diesem Artikel, gewissermaßen als „Proof of Concept“ ein äußerst einfaches CMS auf dieser Basis umsetzen.

The Name of the GameThe Name of the Game

Wir nennen unser CMS „µCMS“ (gesprochen „MicroCMS“), um seinen minimalistischen Ansatz zu betonen. Es soll folgende Möglichkeiten bieten:

  • Hinzufügen von Textdateien (das können z. B. HTML-, JavaScript- oder CSS-Dateien sein)
  • Editieren der genannten Dateien direkt im Browser
  • Löschen von Dateien
  • Anzeigen der entsprechend erzeugten Seiten

Es soll dabei möglich sein, eine Voransicht der editierten Seiten zu bekommen, auf deren Grundlage dann entschieden werden kann, ob die Änderung veröffentlicht werden soll. Neben seiner Funktion als Editor für Webinhalte soll µCMS auch als Webserver dienen, der es normalen Anwendern erlaubt, die betreffenden Inhalte zu betrachten. Wir sehen zwei Anwendergruppen vor:

  • Administratoren/Editoren (in der Folge immer als „Administrator“ bezeichnet): Diese können Inhalte wie oben beschrieben hinzufügen, ändern und löschen.
  • Benutzer: Sie können auf die Inhalte lediglich lesend zugreifen.

Die unterschiedlichen Zugriffe bedingen, dass die Anwenderrollen unterschieden werden können. Wir werden dafür eine einfache Authentifizierung für den Administrator implementieren. Das Speichern der Inhalte erfolgt nicht über eine Datenbank, sondern durch einfaches Ablegen der Inhalte im Dateisystem. Ausdrücklich nicht im Fokus des Projekts steht die ansprechende Gestaltung der Benutzeroberfläche.

Abb. 1: Interface-Flow-Diagram der Anwendung

Benutzeroberfläche und Interaktionen

Abbildung 1 beschreibt aus Sicht eines Administrators die jeweils dargestellten Seiten und Übergänge zwischen diesen, die auf Grundlage der Aktionen des Anwenders durchlaufen werden. Durch Navigation der Route /admin in einer bestehenden Installation, z. B. localhost:8000/admin, gelangt der Anwender zur Seite Login form page, die der Authentifizierung dient. Sie enthält zwei Eingabefelder für Username und Passwort. Nach deren korrekten Eingabe und Betätigung der Schaltfläche LOGIN gelangt der jetzt als Administrator authentifizierte Benutzer zur Explorer page. Sie zeigt alle existierenden Dateien im verwalteten Verzeichnis untereinander an. Hinter jeder Datei existieren die Schaltflächen EDIT und DELETE. Außerdem enthält die Seite eine Schaltfläche ADD mit einem Feld zur Eingabe eines Dateinamens sowie eine Schaltfläche LOGOUT.

Durch Drücken von EDIT gelangt der Benutzer zur Seite Edit file form page. Die zu editierende Datei wird dabei aus dem allgemein zugänglichen Bereich in ein für normale Anwender nicht sichtbares Vorschauverzeichnis kopiert. Die Seite enthält ein editierbares Textfeld, in dem der Anwender nun den Inhalt der Datei editieren kann. Handelt es sich um eine HTML-Datei, wird eine Schaltfläche PREVIEW angeboten, für andere Dateien gibt es stattdessen eine Schaltfläche SAVE. Unabhängig vom Dateityp gibt es eine weitere Schaltfläche PUBLISH sowie eine Schaltfläche LOGOUT. Handelt es sich um eine HTML-Datei und wird die Schaltfläche PREVIEW betätigt, wird der Inhalt des Textfelds in die Datei im Vorschauverzeichnis gespeichert und von dort aus angezeigt. Zur Edit file form page gelangt der Anwender zurück über die Operation Zurück des Browsers. Handelt es sich um keine HTML-Datei, bewirkt das Betätigen von SAVE lediglich das Speichern des Inhalts des Textfelds in die Datei im Vorschauverzeichnis. Es wird weiterhin die Edit file form page angezeigt. Wird aus der Edit file form page die Schaltfläche PUBLISH betätigt, wird die Datei aus dem Vorschauverzeichnis in das allgemein zugängliche Verzeichnis übernommen.

Eine Betätigung von VIEW aus der Explorer page zeigt die entsprechende Datei im Browser an. Der Anwender gelangt über die Operation Zurück des Browsers zurück zur Explorer page. Wird die Schaltfläche ADD aus der Explorer page betätigt, wird eine leere Datei mit dem angegebenen Dateinamen angelegt. Anschließend gelangt der Anwender wieder zur Explorer page, die den aktualisierten Inhalt des Verzeichnisses anzeigt. Die Betätigung von DELETE aus der Explorer page bewirkt das Löschen der entsprechenden Datei. Anschließend gelangt der Anwender wieder in die Explorer page, die den aktualisierten Inhalt des Verzeichnisses anzeigt. Wird aus der Explorer page oder der Edit file form page die Schaltfläche LOGOUT betätigt, wird der Anwender abgemeldet und die Logout page angezeigt. Für normale, nicht authentifizierte Benutzer gilt: Die Navigation zur Basisseite / bewirkt, dass

  • falls im Verzeichnis public eine Datei index.html existiert, diese angezeigt wird oder
  • eine Anzeige des Verzeichnisinhalts von public erfolgt, wobei die Dateien als klickbare Links angezeigt werden.

Die Navigation zu einer existierenden Seite im Verzeichnis public sorgt dafür, dass diese angezeigt wird; die Navigation zu einer der Administratoren vorbehaltenen Seiten (Explorer page, Edit file form) bewirkt die Anzeige einer Seite, die darüber informiert, dass eine Authentifizierung notwendig ist. Sie enthält eine Schaltfläche LOGIN, über die der Anwender zur Anmeldung in die Login form page gelangen kann.

Verzeichnislayout

Wir wählen für unsere Anwendung ein entsprechend minimalistisches Verzeichnislayout:

+ - ./ : Installationsverzeichnis
  |
  + - ucms_express_server.js
  |
  + - public/ : Enhält öffentlich sichtbare Dateien
  |
  + - ucms/ : interne, nicht öffentliche Dateien
    |
    + - .password: Enthält die Login-Information für den Administratoren
    |
    + - drafts/: Enthält Dateien in Bearbeitung

Im Installationsverzeichnis selbst befindet sich der Code unserer Anwendung in der Datei ucms_express_server.js. Wird die Anwendung das erste Mal gestartet, wird die beschriebene Verzeichnisstruktur erzeugt. Das Verzeichnis public enthält die Dateien, die öffentlich zugänglich und daher auch für nicht angemeldete Anwender sichtbar sind. Das Verzeichnis ucms enthält Daten, die für den Betrieb als Administrator benötigt werden. Die ASCII-Datei ucms/.passwort beinhaltet die Zugangsdaten für den Administrator. Um sich als Administrator anmelden zu können, sollte die Datei mit einem Texteditor angelegt und {„user“:“admin“,“password“:“override“} eingefügt werden. Damit ist es dann möglich, sich als Benutzer admin mit dem Passwort override anzumelden.

Das Verzeichnis ucms/drafts dient dazu, editierte Dateien zwischenspeichern und betrachten zu können, ohne sie sofort veröffentlichen zu müssen. Das ermöglicht es, die Änderungen mehrfach zu bearbeiten und zu betrachten, bevor sie dann nach Abschluss des Editiervorgangs in den öffentlichen Bereich übernommen werden, indem sie in den Ordner public kopiert werden.

Die beschriebene Struktur wurde ausgewählt, weil sie relativ einfach und nur minimale Konfiguration notwendig ist. Für ein echtes System wäre vielleicht die Möglichkeit, die Pfade über Umgebungsvariablen oder Konfigurationsdateien konfigurieren zu können, wünschenswert.

International PHP Conference 2018

Getting Started with PHPUnit

by Sebastian Bergmann (thePHP.cc)

Squash bugs with static analysis

by Dave Liddament (Lamp Bristol)

API Conference 2018

API Management – was braucht man um erfolgreich zu sein?

mit Andre Karalus und Carsten Sensler (ArtOfArc)

Web APIs mit Node.js entwickeln

mit Sebastian Springer (MaibornWolff GmbH)

Technik

µCMS ist als minimalistisches System angelegt, und auch die Implementierung soll entsprechend minimal sein. Das bedeutet, dass wir möglichst wenig Code selbst schreiben wollen. Stattdessen verwenden wir existierende Bibliotheken, um die Implementierung kompakt zu halten. Wir benutzen Node.js als Interpreter und folgende Bibliotheken:

  • fs für den Dateisystemzugriff
  • path für entsprechende Pfadoperationen
  • Express als Webframework
  • jsdom, um in Node.js DOM-Strukturen wie im Browser-JavaScript verwenden zu können
  • body-parser, um die Inhalte von POST-Requests einfach auswerten zu können, sowie
  • mkdirp, um Verzeichnisse ggf. anlegen zu lassen

Insbesondere Express stellt für unsere Anwendung fertige Funktionalität (Routing, Funktion als Webserver) bereit, die selbst zu implementieren deutlich aufwendiger wäre.

Da die komplette Anwendung im Kontext von Node.js implementiert wird, können im Code der Anwendung ohne Rücksicht auf Browserkompatibilität die Features von ECMAScript 2015 verwendet werden. Wir werden davon aber nur eingeschränkt Gebrauch machen und Sprachfeatures wie etwa Klassen nicht benutzen, wenn sie die Implementierung nicht vereinfachen. ECMAScript-2015-Sprachfeatures nutzen wir im Wesentlichen in folgenden Fällen:

  • Wir verwenden let anstelle von var bei der Deklaration von Variablen.
  • Wir benutzen ES2015-Templatestrings, um Inhalte von Templatestrings durch Variablen zu ersetzen.

Implementierung

Der vollständige Code ist auf der Webseite zu dem Artikel verfügbar. Wir gehen ihn nun nach und nach durch. Die Implementierung beginnt mit einer Reihe von require-Statements zum Importieren von Bibliotheken:

 
const fs = require("fs");
const path = require('path');
const express = require('express');
const jsdom = require("jsdom");
const { JSDOM } = jsdom;
const bodyParser = require('body-parser');
const mkdirp = require('mkdirp');

Anschließend definieren wir symbolische Variablen für die unter dem Punkt „Verzeichnislayout“ beschriebenen Verzeichnisse und Dateien:

 
const PUBLIC = "./public";
const DRAFTS = "./ucms/drafts";
const PASSWORDFILE = "./ucms/.password";

Der Abschnitt

const uCmsHeader = "µCMS: a micro content management system"; 
let loggedIn = false; 
let user = "";

definiert mit uCmsHeader eine Konstante für einen Header, den wir mehrfach verwenden werden; µ ist die HTML-Schreibweise für das Zeichen µ. Die globale Variable loggedIn gibt an, dass der Benutzer initial nicht eingeloggt ist. user wird aus dem gleichen Grund mit einem Leerstring belegt: Bei der Anmeldung soll es noch keinen Benutzer geben.

Mit let urlencodedParser = bodyParser.urlencoded({ extended: false }); wird ein Parser erzeugt, der zum Auslesen des Attributs body eines Requests verwendet werden kann. Mit der forEach-Anweisung

 
[PUBLIC, DRAFTS].forEach((pth) => {
  mkdirp(pth, (err) => {
    if (err) {
      console.log(err);
    }
  });
});

stellen wir sicher, dass die von der Anwendung benötigten Pfade existieren. Das ist die Aufgabe von mkdirp: Wenn die betreffenden Verzeichnisse nicht existieren, werden sie angelegt. Der Zugang zu geschützten Inhalten wird über die Funktion checkAccess() gewährleistet:

 
function checkAccess(req, res, next) {
  if (loggedIn || (req.path === "/admin") || (req.path === "/login") ||
  (req.path === "/")) {
    next();
  }
  else {
    res.send(notAuthorizedPage());
  }
}

Die Funktion wird als Middleware in Express eingebunden. Ist der Anwender eingeloggt oder ist die Route /admin, /login oder /, ist der Zugriff erlaubt und die Funktion next() wird aufgerufen. Andernfalls wird mit notAuthorizedPage() eine Seite erzeugt, die darauf hinweist, dass der Anwender nicht angemeldet ist, und diese Seite als Antwort an den Client geschickt.

Wir erzeugen dann eine Express-Applikation und binden Middleware-Funktionen ein:

const app = express();
app.use(express.static(PUBLIC));
app.use("/", checkAccess);

app.use(express.static(PUBLIC)) bewirkt, dass die in Express eingebaute Middleware express.static() verwendet wird, um statische Inhalte anzuzeigen. Damit fungiert unsere Applikation bereits als Webserver für statischen Inhalt im Verzeichnis PUBLIC.

app.use(„/“, checkAccess) sorgt dafür, dass für alle Behandlungen von Routen unterhalb von / zunächst die beschriebene Middleware checkAccess() aufgerufen wird. Wir erzeugen nun einen Server, der auf den Port 8000 lauscht:

 
let server = app.listen(8000, function () {
  let host = server.address().address
  let port = server.address().port

  console.log("uCMS listening at http://%s:%s", host, port)
})
app.get('/', function (req, res) {
  fs.readdir(PUBLIC, function(error, files) {
    if (error) {
      res.status(500).send(error.toString());
    }
    else {
      if (files.includes("index.html")) {
        res.redirect("/index.html");
      }
      else {
        res.send(directoryListing(files));
      }
    }
  })
})

Es folgt die Behandlung von GET für die Route / (Listing 1). Die Syntax ist hier einfach app.<Methode>(<Route>, function(<request>, <response>). Wenn die Route / navigiert wird, werden mit fs.readdir() die Dateien im Verzeichnis PUBLIC gelesen. Im Fehlerfall wird der Fehler mit dem Code 500 (= „Internal Server Error“) an den Client geschickt. Andernfalls wird geprüft, ob unter den gefundenen Dateien eine Datei index.html ist. Ist das der Fall, wird sie über redirect() angezeigt, sonst wird eine Auflistung aller Dateien in PUBLIC erzeugt und zurückgegeben. Das geschieht über die Funktion directoryListing(files), die aus den übergebenen Dateien eine entsprechende Seite erzeugt.

Wir kommen jetzt zur Behandlung von GET-Anfragen für die Route /admin. Da die Methode etwas länger ist, nehmen wir sie uns Stück für Stück vor (Listing 2).

app.get("/*admin", function(req, res) {
  let tmplStr;
  if (loggedIn) {
    tmplStr = (`
      <!DOCTYPE html>
      <html>
      <head></head>
      <body>
      <h1>${uCmsHeader}</h1>
      <h2>You are already logged in</h2>
      </body>
      </html>`
    );
  }

Die Syntax app.get(„/*admin“, function(req, res) … bewirkt, dass hier ein regulärer Ausdruck verwendet wird, d. h., die Behandlung wird für alle Pfade, die mit admin enden, durchgeführt. Als Erstes wird die Variable tmplString deklariert. Sofern der Anwender angemeldet ist, wird nun ein String generiert, der eine HTML-Struktur repräsentiert. Die Schreibweise mit vorangestelltem Backtick (`) bewirkt, dass der String als ECMAScript-2015-Templatestring interpretiert wird und die Variablenvorkommen im String ersetzt werden. In unserem Fall wird in dem h1-Element der Platzhalter ${uCmsHeader} durch den Wert der Variablen uCmsHeader ersetzt. Darunter folgt ein h2-Element mit dem Hinweis, dass der Anwender bereits eingeloggt ist. Ist der Anwender dagegen nicht angemeldet, wird ein anderer Templatestring erzeugt (Listing 3).

else {
  tmplStr = (`
    <!DOCTYPE html>
    <html>
      <head></head>
      <body>
      <h1>${uCmsHeader}</h1>
      <h2>Login form</h2>
      <form action="/login" method="post">
      <table>
      <tr>
      <td>
      <label>User</label>
      </td>
      <td>
      <input id="user" name="user">
      </td>
      </tr>
      <tr>
      <td>
      <label>Password</label>
      </td>
      <td>
      <input id="password" name="password" type="password">
      </td>
      </tr>
      </table>
      <button type="submit">Login</button>
      </form>
      </body>
    </html>`
    );

  }
}

Das h1-Element ist hierbei das gleiche wie im vorigen Codeabschnitt. Das h2-Element kennzeichnet die Seite als Log-in-Formular. Danach folgt ein Formular mit zwei Eingabefeldern nebst dazugehörigen Beschriftungen für Anwender und Passwort. Für das zweite Feld wird der Typ passwort gewählt, um zu verhindern, dass die Buchstaben bei der Eingabe anzeigt werden. Für das Formular ist als Action der Pfad /login eingetragen, die Methode ist post. Weiterhin gibt es eine Schaltfläche mit der Beschriftung LOGIN. Die Betätigung der Schaltfläche bewirkt hier also, dass die Route /login mit einem POST Request angesprungen wird, wobei die Formulardaten mit dem Request gesendet werden. Der Rest der Funktion ist einfach:

  let dom = new JSDOM(tmplStr);
  res.send(dom.serialize());
})

Es wird ein JSDOM-Objekt angelegt, wobei der Templatestring tmplStr übergeben wird. Das bewirkt, dass der Inhalt des Strings (in unserem Fall eine HTML-Struktur) eingelesen und daraus ein DOM generiert wird. Anschließend wird dieses DOM mit serialize() in einen String umgewandelt und dieser als Antwort an den Client zurückgegeben. In diesem speziellen Fall wäre es auch möglich gewesen, hier einfach tmplStr zu senden: res.send(dom.serialize());. Im Sinne einer einheitlichen Verarbeitung ist es aber nicht wünschenswert, direkt zusammengebaute HTML-Strings zurückzugeben, da die Umwandlung in ein DOM-Objekt bewirkt, dass DOM-Strukturen wie im Browser behandelt werden. Obwohl etwa ein table-Element angelegt werden kann, ohne dass ein thead-Element angegeben wird, wird dieses implizit vom Browser hinzugefügt. Dasselbe geschieht hier durch das Einlesen mit JSDOM. Listing 4 zeigt die Behandlung für die eben beschriebene Route /login.

app.post("/*login", urlencodedParser, function(req, res) {
  let content = JSON.parse(fs.readFileSync(PASSWORDFILE));
  if ((content.user != req.body.user) ||
    (content.password != req.body.password)) {
    res.status(500).send("Invalid username/password!");
  }
  else {
    loggedIn = true;
    res.redirect("/explorer");
  }
})

Für diese Route wird wieder urlencodedParser als Middleware eingebunden, um sicherzustellen, dass vor Aufruf der eigentlichen Handler-Funktion die Formulardaten ausgelesen und decodiert vorliegen. Darin wird dann die Passwortdatei eingelesen, wobei ein JSON-Objekt als Inhalt der Datei erwartet wird. Entsprechen Anwender und Passwort aus der Passwortdatei nicht den entsprechenden Werten aus dem Formular, wird eine Fehlermeldung gesendet. Andernfalls wird die globale Variable loggedIn auf true gesetzt, um zu signalisieren, dass der Anwender eingeloggt ist. In diesem Fall wird zur Route /explorer umgeleitet. Das Abmelden erfolgt ähnlich über eine Route /logout (Listing 5).

app.get("/*logout", function(req, res) {
  let tmplStr = (`
    <!DOCTYPE html>
    <html>
    <head></head>
    <body>
    <h1>${uCmsHeader}</h1>
    <h2>Logged out</h2>
    </body>
    </html>`
  );

  loggedIn = false;
  const dom = new JSDOM(tmplStr);

  res.send(dom.serialize());
})

Es wird wieder ein HTML-String für die Antwort aufgebaut. Dann wird die Variable loggedIn erneut auf false gesetzt. Abschließend wird der String wieder über JSDOM eingelesen, serialisiert und als Ergebnis zurückgegeben. Die Route /explorer wird über ein GET Request navigiert:

app.get("/explorer", function (req, res) {
  sendExplorerPage(res);
})

Wird diese Route navigiert, wird die Explorer-Seite zurückgegeben. Sie zeigt den Inhalt des Verzeichnisses PUBLIC und erlaubt darüber hinaus Änderungen des Inhalts.

Die Route /add_file wird mit einem POST Request angesprochen, sobald aus der Explorer-Seite die Schaltfläche ADD FILE betätigt wird. Entsprechend wird als Methode hier post verwendet:

app.post("/add_file", urlencodedParser, function (req, res) {
  let outStream = fs.createWriteStream(PUBLIC + "/" + req.body.name);
  outStream.on('error', function(error) {
    res.status(500).send(error.toString());
  });
  outStream.end();
  sendExplorerPage(res);
})

Der Dateiname wird als Attribut name aus einem Formular geschickt. Die eingeschobene Funktion urlencodedParser bewirkt, dass zunächst der Body des Requests ausgelesen und decodiert wird. Das stellt sicher, dass in der folgenden Handler-Funktion dieser Wert als req.body.name zur Verfügung steht. Es wird dann im Verzeichnis PUBLIC eine gleichnamige leere Datei angelegt. Kommt es zu einem Fehler, wird die Fehlermeldung zum Client geschickt, andernfalls wird eine aktualisierte Explorer-Seite gesendet. Der Weg in die Edit file form page wird über die in Listing 6 gezeigte Route betreten.

app.get("/*edit_file_form", function (req, res) {
  let route = "/edit_file_form";
  let strippedPath = req.url.slice(0, -route.length);
  let fileName = path.basename(strippedPath);
  let fullPath = PUBLIC + "/"  + fileName;
  let editPath = DRAFTS + "/" + fileName;
  fs.copyFile(fullPath, editPath, (err) => {
    if (err) {
      res.status(500, err.toString());
    }
    else {
      let responseString = createEditPage(fullPath);
      res.send(responseString);
    }
  });
})

Der URL für das entsprechende POST Request ist z. B. http://localhost:8000/index.html/edit_file_form. Davon wird dann der String /edit_file_form abgeschnitten (strippedPath) und auf den Rest die Funktion path.basename() angewendet, um den Dateinamen ohne Pfad zu erhalten (fileName). Dann wird ein Pfad aus PUBLIC und fileName gebildet, der den tatsächlichen Pfad der Datei im Dateisystem wiedergibt (fullPath). Diese Datei wird anschließend mit fs.copyfile() in das Verzeichnis DRAFTS kopiert, sodass Dateien editiert werden können, ohne dass die Änderungen sofort für die Allgemeinheit sichtbar werden. Im Fehlerfall wird eine entsprechende Meldung an den Client geschickt, ansonsten wird mit dem ermittelten Pfad die Funktion createEditPage() aufgerufen, die die Seite mit dem Formular zum Editieren der Datei erzeugt und diese Seite zurückgeschickt.

app.post("/*delete", function (req, res) {
  let route = "/delete";
  let strippedPath = req.url.slice(0, -route.length);
  let fileName = path.basename(strippedPath);
  let toDelete = PUBLIC + "/" + fileName;
  fs.stat(toDelete, function(error, stats) {
    if (error && error.code == "ENOENT") {
      res.status(204).send(error.toString());
    }
    else if (error) {
      res.status(500, error.toString());
    }
    else if (stats.isDirectory()) {
      let response = toDelete + " is a directory and cannot get deleted.";
      res.send(response);
    }
      else {
        fs.unlink(toDelete, function(error) {
          if (error) {
            res.status(500).send(error.toString());
          }
          else {
            sendExplorerPage(res);
          }
      });
    }
  });
})

Zum Löschen von Dateien dient die Behandlung der Route /*delete_file (Listing 7). Auch hier erhalten wir einen ähnlichen URL für die Anfrage, etwa http://localhost:8000/index.html/delete_file. Analog zur Behandlung von /*edit_file_form ermitteln wir aus dem URL den Dateinamen (fileName) und den Pfad der zu löschenden Datei (toDelete). Wir verwenden die Funktion fs.stat(), um zu ermitteln, ob der entsprechende Pfad nicht existiert oder einem Verzeichnis entspricht. In beiden Fällen geben wir einen Fehler zum Client zurück. Handelt es sich um einen Dateipfad, wird versucht, diese Datei mit der Funktion fs.unlink() zu löschen. Im Fehlerfall wird wieder der Fehler zurückgegeben, andernfalls wird wieder die Explorer page erzeugt und an den Client geschickt.

Der nächste Teil des Codes dient der Behandlung der Betätigung der Schaltfläche SAVE aus der Edit file form page (Listing 8).

app.post("/*save_content", urlencodedParser, function (req, res) {
  let route = "/save_content";
  let strippedPath = req.url.slice(0, -route.length);
  let fileName = path.basename(strippedPath);
  let toSave = DRAFTS + "/" + fileName;
  fs.open(toSave, 'wx', (err, fd) => {
    fs.writeFile(toSave, req.body.source_code, (err) => {
      if (err) {
        res.status(500).send(err.toString());
      }
      else {
        let responseString = createEditPage(toSave);
        res.send(responseString);
      }
    });
  });
})

Der URL ist in diesem Fall ähnlich wie eben schon mehrfach beschrieben. Als Middleware wird auch hier urlencodedParser verwendet, da wir wieder Formulardaten aus dem Body des Requests auslesen müssen. Es wird dann der Pfad zu der Datei im Verzeichnis DRAFT ermittelt, die Datei zum Schreiben geöffnet, der Inhalt des Editierfelds (req.body.source_code) in die Datei geschrieben und im Erfolgsfall die Edit file form page erneut erzeugt und zurückgeliefert.

Wird aus der Edit file form page die Schaltfläche PREVIEW betätigt, kommt der in Listing 9 gezeigte Code zum Tragen.

app.post('/*preview_html', urlencodedParser, function(req, res) {
  let route = "/preview_html";
  let strippedPath = req.url.slice(0, -route.length);
  let fileName = path.basename(strippedPath);
  let editPath = DRAFTS + "/" + fileName;
  fs.open(editPath, 'wx', (err, fd) => {
    fs.writeFile(editPath, req.body.source_code, (err) => {
      if (err) {
        res.status(500).send(err.toString());
      }
      else {
        res.sendFile(__dirname + "/"  + editPath);
      }
    });
  });
});

Die Behandlung ist hier weitgehend gleich wie eben für die Route /*save_content beschrieben. Der Unterschied ist nur, dass hier res.sendfile() mit dem Pfad der zu editierenden Datei aufgerufen wird. Damit wird die editierte Datei als Antwort zum Client geschickt. Das bewirkt, dass der Inhalt der gespeicherten Datei angezeigt wird.

Die Route /publish_file wird verwendet, um nach Abschluss der Änderungen diese in den öffentlichen Bereich zu übertragen (Listing 10).

app.post("/*publish_file", function (req, res) {
  let route = "/publish_file";
  let strippedPath = req.url.slice(0, -route.length);
  let fileName = path.basename(strippedPath);
  let fullPath = PUBLIC + "/" + fileName;
  let sourcePath = DRAFTS + "/" + fileName;
  fs.copyFile(sourcePath, fullPath, (err) => {
    if (err) {
      res.status(500, error.toString());
    }
    else {
      res.redirect("/explorer");
    }
  });
})

Es wird wieder der Dateiname fileName ermittelt; anschließend wird diese Datei aus dem Verzeichnis DRAFT in das Verzeichnis PUBLIC kopiert. Im Erfolgsfall erfolgt eine Umlenkung auf die Route /explorer, die dann die Anzeige der Explorer page bewirkt.

Damit ist die Behandlung des Routings abgeschlossen. Wir kommen jetzt zu dem Code, der die einzelnen Seiten generiert. Wir beginnen mit der Anzeige des Verzeichnisinhalts für normale Benutzer:

function directoryListing(files) {
  let tmplStr = (`
    <!DOCTYPE html>
    <html>
    <head></head>
    <body>
    <h1>Index of /public</h1>
    <table/>
    </body>
    </html>`
  );

Zunächst generieren wir einen Templatestring für den Seitenrumpf. Er enthält einen Header h1 für die Überschrift und ein leeres table-Element:

let dom = new JSDOM(tmplStr);
  let table = dom.window.document.getElementsByTagName("table")[0];

Als Nächstes genieren wir ein DOM-Objekt und ermitteln daraus mit getElementsByTagName() das table-Element:

files.forEach(function (file) {
  let href = "/" + file;
  let tmplStr = (`
  <tr>
  <td>
  <a href="${href}" style="text-decoration: none">${file}</a>
  </td>
  </tr>`
);

Dann durchlaufen wir eine Schleife über die übergebenen Dateien. Wir belegen href mit dem Namen der Datei und einem vorangestellten /. Anschließend erzeugen wir wieder einen Templatestring, der ein tr-Element und darin ein td-Element enthält. Innerhalb des td-Elements wird ein Linkelement (a) angelegt. Über Variablenersetzung wird für das Attribut href des Links der Wert der Variable href, für den Inhalt des Linkelements der Wert von file eingesetzt. Wir setzen das style-Attribut auf den Wert text-decoration: none, um zu verhindern, dass die Links unterstrichen dargestellt werden:

const tr = JSDOM.fragment(tmplStr);
table.appendChild(tr);
});
return dom.serialize();
}

Wir benutzen dann JSDOM.fragment(), um aus unserem Templatestring ein tr-Element zu erzeugen und hängen dieses an das table-Element an. Damit ist die Schleife beendet. Abschließend liefern wir das zum String serialisierte DOM zurück. Abbildung 2 zeigt das Ergebnis für ein Verzeichnis mit drei Dateien.

Abb. 2: Anzeige des Verzeichnisinhalts für einen nicht angemeldeten Benutzer

Es folgt eine Hilfsfunktion, die Dateien im PUBLIC-Verzeichnis ermittelt (Listing 11).

function sendExplorerPage(res) {
  fs.readdir(PUBLIC, function(error, files) {
    if (error) {
      res.status(500).send(error.toString());
    }
    else {
      let responseString = createExplorerPage(files);
      res.send(responseString);
    }
  });
}

Sie liest mit fs.readdir() den Inhalt des Verzeichnisses PUBLIC aus. Im Fehlerfall wird der Fehlertext an den Client gesendet. Andernfalls wird mit den ermittelten Dateien die Funktion createExplorerPage() aufgerufen und der zurückgegebene String an den Client geschickt.

function createExplorerPage(files) {
  let tmplStr = (`
    <!DOCTYPE html>
    <html>
    <head></head>
    <body>
    <h1>${uCmsHeader}</h1>
    <h2>Directory listing</h2>
    <form>
    <table></table>
    </form>
    <form>
    <h2>Add new file</h2>
    <label>File name</label>
    <input id="name" name="name">
    <button type="submit" formaction="/add_file"
    formmethod="post">Add file</button>
    </form>
    <form>
     <button type="submit" formaction="/logout" >Logout</button>
    </form>
    </body>
    </html>`
  );

Wir kommen jetzt zur Funktion createExplorerPage() selbst (Listing 12). Wie schon mehrmals gesehen, wird wieder ein Templatestring für den Seitenrumpf erzeugt. Ein h1-Element enthält den üblichen Seitenheader, ein h2-Element die Überschrift „Directory listing“. Darunter befindet sich ein form-Element mit einem leeren table-Element. Die Zeilen dieser Tabelle werden später von den Einträgen für die einzelnen Dateien gebildet.

Ein weiteres form-Element mit der Überschrift „Add file“ enthält ein Textfeld (input) für die Eingabe des Dateinamens sowie ein ebenfalls mit „Add file“ beschriftetes button-Element, bei dessen Betätigung ein POST Request für die Route /add_file durchgeführt wird. Ein drittes form-Element enthält ein button-Element mit der Beschriftung „Logout“. Bei Betätigung wird ein GET Request für die Route /logout ausgeführt:

const dom = new JSDOM(tmplStr);
let table = dom.window.document.getElementsByTagName("table")[0];

Der Templatestring wird nun wieder in ein DOM-Objekt eingelesen und aus dem DOM das table-Element ermittelt. Es folgt eine Schleife, die über die übergebenen Dateien iteriert (Listing 13).

for (let i = 0; i < files.length; i++) {
  let tmplStr = (`
    <tr>
    <td>${files[i]}</td>
    <td>
    <button formaction="/${files[i]}">View</button>
    </td>
    <td>
    <button formaction="${files[i]}/edit_file_form">Edit</button>
    </td>
    <td>
    <button formmethod="post" formaction="${files[i]}/delete">Delete </button>
    </td>
    </tr>`
  );
  const tr = JSDOM.fragment(tmplStr);
  table.appendChild(tr);
}

Für jede Datei wird ein Templatestring erzeugt. Er enthält ein tr-Element als äußere Klammer; darin werden vier td-Elemente angelegt:

  • Das erste Element enthält den Dateinamen.
  • Das zweite Element enthält ein mit VIEW beschriftetes button-Element. Als form-Action wird der Dateiname mit vorangestelltem / gesetzt.
  • Das dritte Element enthält ein mit EDIT beschriftetes button-Element. Als form-Action wird der Dateiname mit vorangestelltem / gesetzt, an den der String /edit_file_form angehängt wird.
  •  Das vierte Element enthält ein mit DELETE beschriftetes button-Element. Als form-Action wird der Dateiname mit vorangestelltem / gesetzt, an den der String /delete angehängt wird.

Anschließend wird aus dem Templatestring wieder über JSDOM.fragment() ein DOM-Element erzeugt und an das table-Element angehängt:

  str = dom.serialize();
  return str;
}

Zum Abschluss wird aus dem DOM wieder ein String erzeugt und dieser zurückgegeben. Ein Beispiel für das Ergebnis ist in Abbildung 3 zu sehen.

Abb. 3: Die Explorer page

Als Nächstes beschäftigen wir uns mit der Funktion createEditPage(), die aufgerufen wird, wenn für eine Datei aus der Explorer page die Schaltfläche EDIT betätigt wird (Listing 14).

function createEditPage(thePath) {
  let fileName = path.basename(thePath);
  let tmplStr = (`
    <!DOCTYPE html>
    <html>
    <head></head>
    <body>
    <h1>${uCmsHeader}</h1>
    <h2>Editing file: "${fileName}"</h2>
    <form id="edit_form">
    <textarea cols="80" rows="20" form="edit_form" name="source_code"></textarea>
    <div>
    <button type="submit" formmethod="post"
formaction="/${fileName}/preview_html">Preview</button>
    <button type="submit" formmethod="post" formaction="/${fileName}/save_content">Save</button>
    <button type="submit" formmethod="post" formaction="/${fileName}/publish_file">Publish</button>
    </div>
    </form>
    <form>
    <button type="submit" formaction="/logout">Logout</button>
    </form>
    </body>
    </html>`
  );

Wir mitteln zunächst mit path.basename(thePath) den Dateinamen der Datei (fileName). Dann erzeugen wir wieder einen Templatestring für den Seitenrumpf. Ein h1-Element enthält den üblichen Header, das folgende h2-Element den Text Editing file:, gefolgt vom Dateinamen. Header und Dateiname werden hier wieder über Variablenersetzung gesetzt. Anschließend folgt ein form-Element. Innerhalb des form-Elements befindet sich ein mehrzeiliges Textfeld (textarea), das benutzt wird, um den Quellcode der Datei anzuzeigen und ggf. zu editieren. Es folgt ein div-Element, das als Klammer für drei enthaltene button-Elemente dient:

  • Der erste Button ist mit PREVIEW beschriftet. Bei Betätigung wird ein POST Request gesendet, als URL wird der Dateiname mit vorangestelltem / und angehängtem /preview_html verwendet.
  • Der zweite Button ist mit SAVE beschriftet. Bei Betätigung wird ein POST Request gesendet, als URL wird der Dateiname mit vorangestelltem / und angehängtem /save_content verwendet.
  • Der dritte Button ist mit PREVIEW beschriftet. Bei Betätigung wird ein POST Request gesendet, als URL wird der Dateiname mit vorangestelltem / und angehängtem /publish_file verwendet.

Anschließend werden das div-Element und das form-Element geschlossen. Ein weiteres form-Element enthält dann wieder ein mit LOGOUT beschriftetes button-Element, wie wir es schon bei createExplorerPage() gesehen haben:

const dom = new JSDOM(tmplStr);
let buttons = dom.window.document.getElementsByTagName("button");

Wir erzeugen dann wieder ein DOM-Objekt aus dem String und setzen die Variable buttons auf die enthaltenen button-Elemente:

if (thePath.endsWith(".html") || thePath.endsWith(".htm")) {
  buttons[1].parentNode.removeChild(buttons[1]);
}
else {
  buttons[0].parentNode.removeChild(buttons[0]);
}

Wenn es sich um eine HTML-Datei handelt, wird das button-Element für SAVE ausgeblendet. In diesem Fall verbleibt nur die Schaltfläche PREVIEW. Andernfalls wird das button-Element für PREVIEW ausgeblendet und nur die Schaltfläche SAVE bleibt erhalten:

let textAreaElem = dom.window.document.getElementsByTagName("textarea")[0];
textAreaElem.setAttribute("name", "source_code");
let content = fs.readFileSync(thePath).toString();
textAreaElem.innerHTML = content;
str = dom.serialize();

return str;
}

Es wird dann aus dem DOM das textarea-Element ermittelt. Das Attribut name des Elements wird auf den Wert source_code gesetzt. Anschließend wird der Inhalt der Datei mit fs.readFileSync() gelesen und als Inhalt des textarea-Elements gesetzt. Das DOM-Objekt wird dann wieder in einen String serialisiert und zurückgegeben. Abbildung 4 zeigt das Editieren der Seite a_html_file.html.

Abb. 4: Die Edit form page

Abschließend kommen wir zu der Funktion notAuthorizedPage(), die zurückgegeben wird, wenn ein nicht angemeldeter Benutzer versucht, auf für den Administrator vorgesehene Seiten zuzugreifen (Listing 15).

function notAuthorizedPage() {
  let tmplStr = (`
    <!DOCTYPE html>
    <html>
    <head></head>
    <body>
    <h1>${uCmsHeader}</h1>
    <h2>You have to be authorized to view this page</h2>
    <form action="/admin">
    <button type="submit">Login</button>
    </form>
    </body>
    </html>`
  );

  const dom = new JSDOM(tmplStr);
  return dom.serialize();
}

Es wird eine Seite zurückgegeben, die darüber informiert, dass eine Anmeldung notwendig ist, um die Seite anzuzeigen. Die Seite enthält weiterhin ein Formular mit einer Schaltfläche zum Anmelden (Route: /admin).

Start der Anwendung

Zum Start der Anwendung wechselt man in einer Shell in das oben beschriebene Installationsverzeichnis, in dem auch die Datei ucms_express_server.js liegt, und startet dann node ucms_express_server.js. Die Anwendung sollte das mit der Ausgabe uCMS listening at http://:::8000 quittieren. Eventuell komm es stattdessen zu einem Traceback wie:

module.js:529
  throw err;
  ^
Error: Cannot find module 'express'
...

In diesem Fall muss das fehlende Modul mit npm nachinstalliert werden. Das muss ggf. für alle als fehlend gemeldeten Module wiederholt und das o. g. Kommando erneut aufgerufen werden. Durch Eingabe des Pfads http://localhost:8000/admin in der Adresszeile des Browsers gelangt man dann zum Anmeldeformular.

Voraussetzung ist, dass eine hinreichend aktuelle Version von Node.js installiert wurde; für diesen Artikel wurde v8.7.0 verwendet. Unter Ubuntu empfiehlt es sich, Node.js über nvm zu installieren, ein entsprechendes Tutorial befindet sich hier.

Fazit

Wir haben in diesem Artikel ein minimales, aber komplett funktionstüchtiges CMS ausschließlich in JavaScript implementiert. Es ist damit möglich, über den Browser Seiten anzulegen, zu ändern, eine Voransicht zu sehen, zu veröffentlichen sowie Seiten zu löschen. Natürlich kann unser Minimal-CMS nicht mit einem „richtigen“ CMS konkurrieren. Vielmehr ging es darum, zu zeigen, welche Möglichkeiten Node.js mit den dazugehörigen Bibliotheken bietet. Aufgrund der Leistungsfähigkeit der genannten Bibliotheken (insbesondere Express) konnte der Code dabei ziemlich kompakt gehalten werden: Der gesamte Code der Anwendung ist 415 Zeilen lang.

Ausblick

Um den Rahmen eines Artikels nicht zu sprengen, wurden einige Aspekte vernachlässigt. Bei Interesse kann der interessierte Leser sie nachrüsten:

  • Konfigurierbare Pfade: Statt eines starren Verzeichnislayouts könnten die Pfade PUBLIC, DRAFTS und PASSWORDFILE über eine Konfigurationsdatei gesetzt werden.
  • Umgang mit nicht veröffentlichten Drafts: In der gegebenen Implementierung ist es so, dass, wenn der Anwender einen editierten Inhalt aus dem Verzeichnis DRAFTS nicht veröffentlicht, er beim nächsten Editieren aus dem PUBLIC-Verzeichnis einfach überschrieben wird. Hier wäre es denkbar, in der Explorer page in solchen Fällen zwei Schaltflächen anzubieten: EDIT PUBLISHED und EDIT DRAFT . Das erlaubt dem Administrator, in einer neuen Session einen älteren Entwurf weiter zu bearbeiten.
  • Oberflächengestaltung: Die Gestaltung der Oberfläche erfolgt in reinem HTML, aus Platzgründen wurde auf die Verwendung von CSS verzichtet. Die Oberfläche könnte mit wenig Aufwand wahrscheinlich sehr viel ansprechender gestaltet werden.
  • Usability/UX: In Hinsicht auf Usability/User Experience sind sicher viele Verbesserungen denkbar. Für die Edit form page könnte man sich z. B. ein Syntax-Highlighting o. Ä. vorstellen oder sogar eine synchrone Darstellung der Auswertung des eingegebenen Quelltexts. Schaltflächen wie ADD FILE in der Explorer page könnten deaktiviert werden, solange kein Zeichen eingegeben ist. Unter diesem Gesichtspunkt wäre aber vor allem eine Analyse der Interaktionen und dessen, was der Anwender in einer konkreten Situation mit dem System tun möchte, von Bedeutung.
  • Authentifizierung/Security: In der aktuellen Implementierung gibt es genau einen Benutzer, der Administrator sein kann, wir haben also ein Einbenutzersystem. Wünschenswert wäre es wahrscheinlich, mehrere Administratorbenutzer zuzulassen. Allerdings müsste dann auch die konkurrierende Bearbeitung von Inhalten durch Sperren oder Reservierungen unterbunden werden. Und auch in Hinsicht auf Security ist die Implementierung eher ein „Proof of Concept“.

Alles in allem möchte dieser Artikel hauptsächlich ermutigen, die entsprechenden Möglichkeiten spielerisch zu erforschen. Inspiriert wurde er durch die Aufgabe „A public space on the web“ aus dem nach wie vor empfehlenswerten Buch „Eloquent JavaScript“ [1], auch wenn die Umsetzung sich von den Vorgaben dieser Aufgabe weit entfernt hat.

Literatur
[1] Haverbeke, Marijn: Eloquent JavaScript, Second Edition, No Starch Press, 2015

PHP Magazin

Entwickler MagazinDieser Artikel ist im PHP Magazin erschienen. Das PHP Magazin deckt ein breites Spektrum an Themen ab, die für die erfolgreiche Webentwicklung unerlässlich sind.

Natürlich können Sie das PHP Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

X
- Gib Deinen Standort ein -
- or -