Webprojekt von Contao zu Ruby on Rails

Warum? Die Story zur Umstellung

Ist Contao nicht genug? Muss es unbedingt ein Ruby on Rails Projekt sein? Diese Frage steht natürlich im Raum.

Ich betreibe das nicht-kommerzielle Projekt (Stand 2024) Die Grüne Welt. Das ist eine Webseite über Zimmer- und Gartenpflanzen mit einem Lexikon und Tipps dazu, wie man das Grün in der Wohnung und im Garten fördern und erhalten kann.
Früher wurde darauf Werbung geschaltet um ein paar Kosten wieder einzuspielen. Durch umfangreiche andere Webseiten und SEO (Suchmaschienenoptimierung) Maßnahmen, die man als kleiner Webseitenbetreiber vom Aufwand nicht stemmen kann, lohnt es sich nicht weitere Artikel zu erstellen. Die Webseite hat kaum noch Geld eingebracht.

Durch ein ohnehin schon kritisches Verhältnis zu Werbung auf Webseiten habe ich die Werbung entfernt. Die Inhalte sind handgeschrieben - also viel zu Schade um die Seite offline zu nehmen.

Betrieben wurde die Seite mit Contao + Metamodels. Leider wird MetaModels, obwohl es ein Open Source Projekt ist, sehr träge öffentlich geupdatet. Wenn man Geld bezahlt, bekommt man den Zugriff auf die aktuellen Sourcen von MetaModels. Die Investition rechnet sich bei diesen Projekt aber nicht.
Daraus folgt, dass auch Contao nicht auf dem aktuellen Stand gehalten werden kann (durch die Abhänigkeiten zum veralteten Metamodels) .

Es ist generell für Web-Projekte eine schlechte Idee, veraltete, nicht supportete Versionen zu betreiben.

Somit kam der Entschluss das selbst in die Hand zu nehmen und nebenbei etwas Komplexität abzubauen. Ruby on Rails ist das Framework der Wahl da ich es kenne, aktiv weiter entwickelt wird und die Speerspitze der Frameworks für Webanwendungen bildet.

Vorraussetzungen

Zum Einsatz kommt das im November 2024 aktuellste Ruby on Rails 8.0 und die Ruby Version 3.3.6.

Was brauchen wir noch an Infrastruktur?

Welche Dinge müssen bei der Umsetzungen beachtet werden?

  • das Layout soll weitestgehend gleich bleiben
  • die URLs sollen gleich bleiben (außer für die statischen Seiten)
  • die Bilder sollen unter dem gleichen Namen erreichbar sein (Suchmaschinen verlinken in der Bildersuche darauf)

 

Legacy Datenbestand aus Contao in Ruby on Rails nutzen

Contao und Rails sind erstmal inkompatibel. Die Aufgabe ist, Rails kompatibel für den Legacy Datenbestand zu machen.
Anmerkung: Es gibt hier unterschiedliche Möglichkeiten das zu erreichen. Ich habe mich dafür entschieden eher wenig Zeit zu investieren um ein schnelles Ergebnis zu erreichen.

Als allererstes wird die Datenbank von Contao so wie sie ist importiert und für alle Tabellen, auf die wir zugreifen wollen, Modelle angelegt (auch tl_files) z.B. für die Pflanzen:

rails g model Plant

Die Inhalte sind über mehrere Tabellen verteilt (das ist ein früherer Design Fehler beim Datenbankdesign). Meine Tabellennamen von Metamodels haben deutsche Namen. Da ich aber später plane (man weiß ja nie) das Rails Projekt zu erweitern habe ich auf englische Namen gesetzt. Metamodels Tablellen habe das Format mm_<tabellenname> .
Um die Tabelle vorerst nicht über eine Migration umzubenennen (das kann man später machen) nutze ich die deutschen Tabellennamen im Model. Dazu reicht folgender Eintrag in dem Rails Modell self.table_name = "mm_pflanzen" . Ab jetzt hat das Modell Zugriff auf die Tabelle und kann damit arbeiten.

Die Texte liegen im Plaintext vor aber Bilder sind in die zentrale tl_files Tabelle von Contao verlinkt. Als Schlüssel wird eine UUID genutzt, die in binärer Form vorliegt.

CREATE TABLE `tl_files` (
	`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
	`pid` binary(16) DEFAULT NULL,
	`tstamp` int(10) unsigned NOT NULL DEFAULT 0,
	`uuid` binary(16) DEFAULT NULL,
	`type` varchar(16) NOT NULL DEFAULT '',
	`path` varchar(1022) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '',
	`extension` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '',
	`hash` varchar(32) NOT NULL DEFAULT '',
	`found` char(1) NOT NULL DEFAULT '1',
	`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '',
	`meta` blob DEFAULT NULL,
	`importantPartX` double unsigned NOT NULL DEFAULT 0,
	`importantPartY` double unsigned NOT NULL DEFAULT 0,
	`importantPartWidth` double unsigned NOT NULL DEFAULT 0,
	`importantPartHeight` double unsigned NOT NULL DEFAULT 0,
	PRIMARY KEY (`id`),
	UNIQUE KEY `uuid` (`uuid`),
	 KEY `path` (`path`(768)),
	 KEY `pid` (`pid`),
	 KEY `extension` (`extension`)
);

Die Tabelle, in der die Einträge für die Pflanzen aus dem Pflanzenlexikon enthalten sind, referenziert ein Teaserbild hier:

`teaserimg` blob DEFAULT NULL,

Nun tritt die erste Schwierigkeit des Projektes auf: wie geht man mit den Binär Blobs um damit man auf den Eintrag in tl_files Zugriff bekommt?

Mit Rails ist das recht einfach:

 belongs_to :old_teaserimg, class_name: "TlFile", foreign_key: :teaserimg, primary_key: "uuid", optional: true

Ich benutze anstatt dem normalen ".teaserimg" Attribute ".old_teaserimage" für die Referenz auf den Eintrag in tl_files und ".img_teaserimg" für den Eintrag von ActiveStorage als Modelattribut um späterer Verwirrung vorzubeugen. ".teaserimg" nutze ich nicht weiter, da es auf den Binärblob verweist und damit nicht zu gebrauchen ist.

Warum das alles?

Es sollen in Zukunft alle Bilder über ActiveStorage verwaltet werden weil das einfacher ist als die alten Strukturen von Contao weiter zu nutzen. Wie wird das erreicht?

Ein Callback nach jedem "find" Aufruf hilft hierbei

after_find do |lex|
  if lex.send("img_#{imagesym}").blank?
    Dgw::Download.image(lex,imagesym)
  end
end


Was ist Dgw::Download ?
In /lib/dgw/download.rb liegt folgendes Modul:

require "uri"
module Dgw
  class Download
    class << self
      def image(model,imageattribute)
        new
        if model.send("img_#{imageattribute}").blank? and model.send("old_#{imageattribute}").present?
          filename = model.send("old_#{imageattribute}").path
          url = URI::DEFAULT_PARSER.escape "https://<hostname>.de/#{filename}".strip
          begin
            puts "Download: " + url
            resource = Faraday.get(url.to_s)
            imagefile = StringIO.new(resource.body)
            puts "Size: " + imagefile.length.to_s
            image_name = File.basename(filename)
            image_mime = Mime::Type.lookup_by_extension(File.extname(filename).delete("."))
            model.send("img_#{imageattribute}".to_sym).attach(io: imagefile,
                                                              filename: image_name,
                                                              content_type: image_mime.to_s,identify: false)
            puts "Attaching: " + image_name
            puts "Mime: " + image_mime.to_s
          rescue StandardError => e
            puts "An error occurred: #{e.message}"
          end
        end
      end
    end
  end
end

Dieses kleine Modul zieht die Bilder aus dem Webspace und speichert sie als ActiveStorage Objekt. Das ist natürlich nur für den Übergang des Projektes gedacht denn später, wenn der Datenbestand übernommen ist, ist das Modul nicht mehr notwendig und kann entfernt werden.

 

AVO als Admininterface

AVO ist eine Entdeckung die ich erst kürzlich machte. Man kann mit AVO sehr sehr schnell ein Admin Interface zusammen bauen. Das Projekt soll nicht viel Zeit verschlingen aber benutzbar sein und auch zum Editieren ein Interface bieten, dass auch noch irgendwie nett aussieht. AVO bietet das.

bundle add avo und bin/rails generate avo:install 

und schon ist AVO installiert.

Die AVO Ressourcen werden so erstellt:

bin/rails generate avo:resource Plant --model-class Plant

Eine Avo Ressource definiert kurz gesagt die verfügbaren Felder im Admin Interface sowie deren Darstellung und das Verhalten der ganzen Ressource etc.

Ein Beispiel davon sieht so aus

class Avo::Resources::Post < Avo::BaseResource
  self.title = :id
  self.includes = []

  def fields
    field :id, as: :id
    field :name, as: :text, required: true
    field :body, as: :trix, placeholder: "Add the post body here", always_show: false
    field :cover_photo, as: :file, is_image: true, link_to_record: true
    field :is_featured, as: :boolean

    field :is_published, as: :boolean do
      record.published_at.present?
    end

    field :user, as: :belongs_to, placeholder: "—"
  end
end

Was ist bei dem Projekt zu beachten gewesen?

Die Texteinträge aus der Tabelle mm_pflanzen sollen einen WYSIWYG Editor bekommen ... also

field :teasertext, as: :trix, always_show: true 

(Trix ist der Editor der von ActionText - einem Rails Bestandteil - bereitgestellt wird) und es soll immer angezeigt werden.

Die Bilder müssen auch noch als Dateien gekennzeichnet werden ungefähr so

field :img_teaserimg, as: :file, is_image: true

Noch zwei interessante Faktoren sind die Sortierung. Hier wird auf die vorhandene Sortierspalte aufgebaut mit dem Eintrag self.default_sort_column = :sorting .

Dargestellt soll es alles als Grid mit  self.default_view_type = :grid sowie

self.grid_view = {
    card: -> do {
        cover_url:
          if record.img_teaserimg.attached?
            main_app.url_for(record.img_teaserimg.url)
          end,
        title: record.pflanzentitel.html_safe,
        body: record.teasertext.html_safe
      }
    end
}

Die Ansicht die AVO generiert sieht dann so aus (Teil des Admin Interfaces).

Zugriffsschutz mit Devise

Damit es schnell geht wird Devise eingesetzt. Ich wollte zwar die neue Möglichkeit von Rails 8, eine Authentifizierung selbst zu schreiben,  testen aber das funktionierte nicht sofort mit der Integration von AVO also habe ich mich für Devise entschieden.

Das heißt Devise GEM zum Rails Projekt hinzufügen, Installation durchführen, Migration anlegen in den Seeds Accounts anlegen und danach AVO über die Routen einbinden:

 authenticate :user do
mount Avo::Engine, at: Avo.configuration.root_path
end

(Siehe dazu etwas weiter oben der Edit Redirect für AVO)

URLs sollen gleich bleiben - Routen anpassen

Um die schon vorhandenen Seiten in den Indices der Suchmaschinen da zu belassen und nicht in einer Hölle an 301 Redirects unterzugehen, werden die Rails Routen so angepasst, dass sie dem alten URL Schema entsprechen.

Das sieht wie folgt exemplarisch für das Pflanzen-ABC so aus:

  get "/pflanzen-abc-uebersicht.html", to: "plants#index", as: "pflanzenabc"
  resources :plants, path: "pflanze", only: [:index, :show], format: false

Hier wird als erstes die Index Seite auf den die Indexmethode des Controllers gelegt und ein name für den Pfad vergeben sodass ich ihn später im Code als pflanzenabc_path oder pflanzenabc_url ansprechen kann.

Die zweite Anweisung bewirkt, dass alle Pflanzen unter /pflanze/<urlalias>.html aufrufbar sind. In Kombination dazu gehört in das Plant Model noch folgende Methode:

  def to_param
    "#{urlalias}.html"
  end

AVO hat in der Zwischenzeit noch eine weitere Route erfordert die vor das Einbinden der Engine gehört:

   get "/avo/resources/:resource/:id.html/edit", to: redirect("/avo/resources/%{resource}/%{id}/edit")

Diese Route ist notwendig, damit man in dem Projekt Ressourcen editieren kann ansonsten wird die Edit Route nicht gefunden. Ob das ein Bug ist oder nicht, ist noch nicht ganz klar aber es liegt zumindest an der ".html" Endung in der "to_param" Methode der Modelle. Die Entwickler sind informiert und mal sehen ... vielleicht ist diese Route irgendwann nicht mehr nötig.

Statische Seiten

Die statischen Seiten wollte ich erst über eine Art selbstgeschriebenes CMS verwalten. Dann bin ich aber eher zufällig über das High Voltage GEM von Thought Bot gestoßen. Damit kann man statische Seiten ganz einfach verwalten. Also installiert und konfiguriert.

Das bedeutet folgender Initializer:


HighVoltage.configure do |config|
  config.routes = false
end</code

Das wird deswegen gemacht, weil ich die Routen selbst setzen muss um die alte URL zu erhalten:

  get '/projekte-events.html' => 'high_voltage/pages#show', id: 'projekte-events'
get "/news/:id.html" => "high_voltage/pages#show", as: :page, format: false

Der Inhalt der Seiten wird in /app/views/pages/ als html.erb Datei angelegt und ganz normal HTML rein geschrieben. Das wars.

Bilder

Dann sind da allerdings noch Bilder verlinkt ... was wird mit denen gemacht? Ich habe es mir einfach gemacht und alle Bilder aus dem /files Verzeichnis nach /public/files kopiert und die von Contao kleiner gerechneten Ansichten aus /assets/images nach /public/assets/images kopiert (ob dass man hier nicht mit rake assets:clobber alles löscht ... vielleicht wäre hier noch ein Redirect nicht schlecht).

Volltextsuche über die Pflanzen

Ich wollte eigentlich eine Volltextsuche wie in Contao über alles. Aber Contao z.B. indiziert die Webseiten "von vorne" was bedeutet, dass ein Bot die schon generierten Seiten aufruft und dann darüber einen Index erstellt und auch darüber gesucht wird.

Bei Rails hätte ich, um über mehrere Modelle zu suchen (also z.B. Plant and Gardenlexicon) eine Elastic Search Instanz einsetzen müssen (vielleicht geht es auch einfacher aber ich wollte nicht weiter darüber nachdenken weil das vielleicht in Zukunft sowieso anders gemacht wird). ElasticSearch würde aber alles verkomplizieren und viel zu viel Zeit fressen. Diese ganze Misere ist dem etwas seltsamen Datenbankdesign geschuldet.

Also habe ich mich dazu entschieden, nur über die Pflanzen eine Volltextsuche zu machen. Dazu kommt das SearchCop GEM zum Einsatz. Das GEM wird installiert, ein Controller angelegt, ein paar Ausgabe- und Eingabeseiten und/oder -partials erstellt und fertig ist die Suche.

Die Konfiguration von Search Cob im Model sieht wie folgt aus:


  search_scope :search do
    attributes :pflanzentitel,
               :pflanzenname_deutsch,
               :pflanzenname_latein,
               :teasertext,
               :standort_und_pflege,
               :krankheiten_und_schaedlinge,
               :vermehrung,
               :weitere_infos


    options  [:pflanzentitel,
              :pflanzenname_deutsch,
              :pflanzenname_latein,
              :teasertext,
              :standort_und_pflege,
              :krankheiten_und_schaedlinge,
              :vermehrung,
              :weitere_infos], type: :fulltext

  end

Mehr braucht es nicht für eine Suche. Die ganze Suche kann aber mit ein paar Indices in der Datenbank noch optimiert werden. Ich habe darauf jetzt verzichtet weil das etwas für die Zukunft ist und gerade nicht notwendig.

SEO

SEO Maßnahmen (also primär anständige Titel und Meta Descriptions) wurden in der Contao-Ausführung der Webseite von mir etwas vernachlässigt (es macht einfach keinen Spaß PHP Templates anzupassen). Das habe ich jetzt nachgeholt.

Dazu kommt das GEM "meta-tags" zum Einsatz, dass ein paar nette Helfer für Rails zur Verfügung stellt um allerlei nützliche Tags im HTML Header zu generieren.

Auf Details davon gehe ich hier mal nicht weiter ein weil es reichlich uninteressant ist und nach ein paar Minuten die Bedienung des meta-tags GEMs beherrscht werden kann.

Deployment & Abschließende Worte

Das Deployment wollte ich mit Kamal2 machen. Es erschien mir aber bei meiner bestehenden Konstellation als zu komplex und ich kenne Capistrano schon. Also habe ich mich für den alten Bekannten "Capistrano" entschieden. Prinzipiell ist es das übliche ... man schiebt seinen Branch per Git auf den Server und beim Deployment mit Capistrano wird aus dem Git der aktuelle HEAD gezogen und alle Maßnahmen für die neue Version getroffen. Das alles läuft über SSH.

Ansonsten wird ein NGINX mit einem Reverse Proxy ausgestattet der Lokal auf die Rails Instanz weiterleitet. Gestartet wir die Rails Instanz über lokale Systemd Anweisungen beim Boot des Servers (hier sollte man tatsächlich Linger aktivieren wie sonst die Rails Instanz wieder gestoppt wird sobald man sich ausloggt)

Alles in allem war es ein interessantes Projekt wo mit begrenzten Zeiteinsatz mal eine Webseite mit einem Legacy Datenbestand wieder online gebracht werden sollte. In Zukunft wird die Seite vielleicht noch erweitert und/oder interne Strukturen verbessert.

Das Hauptziel ist aber erreicht: Die Webseite ist unabhängig von den Entwicklungsintentionen externer Projekte. Sie ist dadurch weiterhin auf dem letzten Stand haltbar und weniger im engen Rahmen von Contao erweiterbar.

Fragen gerne an