Erscheinungsbild
0012 — .htaccess als erzeugtes Deployment-Artefakt
Status: angenommen · Milestone: M8
Kontext
Ein statisches Deployment auf Apache braucht eine Reihe von Serverregeln, die keine Astro-Seite ausdrücken kann: HTTPS-Weiterleitung, kanonische Domain, Cache-Header, Kompression, Sicherheits-Header, eine eigene Fehlerseite, abgeschaltetes Verzeichnislisting und die Weiterleitungen des Projekts.
Der naheliegende Weg wäre eine Datei public/.htaccess. Astro kopiert public/ unverändert nach dist/, und damit wäre sie ausgeliefert.
Drei Dinge sprechen dagegen, und alle drei fallen erst später auf.
Ihr Inhalt hängt von der Projektkonfiguration ab. Die kanonische Domain, die Weiterleitungen, HSTS, der Modus der Content Security Policy — das alles steht in project.config.ts. Eine statische Datei müsste dieselben Werte ein zweites Mal enthalten.
Ihr Inhalt hängt vom erzeugten HTML ab. Die Content Security Policy deckt die Inline-Skripte über ihre Hashes ab (ADR 0013). Diese Hashes kennt erst der Build.
Auf GitHub Pages wäre sie eine Lüge. Dort liest sie niemand. Eine Datei, die Sicherheits-Header zusagt und keinen davon ausliefert, ist schlimmer als keine Datei — sie beantwortet die Frage „haben wir Security-Header?" mit ja.
Entscheidung
Die .htaccess entsteht beim Bauen, aus der Projektkonfiguration und dem erzeugten HTML. Zuständig ist die Integration kicktemp:apache in src/lib/deployment/integration.ts; den Text erzeugt ein reiner Renderer in htaccess.ts.
Registriert wird die Integration nur bei deployment.target: 'mittwald-static'. Bei github-pages entsteht keine Datei, und die Konfigurationsprüfung lehnt eine deployment.apache-Sektion dort ab.
Es entstehen zwei Dateien. Die zweite liegt im Assets-Verzeichnis (_astro/) und erlaubt dort unbegrenztes Zwischenspeichern.
Der Renderer erzeugt Text, keine Datei. Jede Regel ist damit ohne Build und ohne Dateisystem prüfbar.
Begründung
Warum zwei Dateien und nicht eine Regel für beide Fälle. <Location> und <LocationMatch> sind in einer .htaccess nicht erlaubt — das ist keine Konvention, sondern eine Einschränkung von Apache. Bliebe ein <FilesMatch> auf das Hash-Muster der Dateinamen, also eine Vermutung darüber, wie Astro seine Dateien benennt. Ein Verzeichnis, in dem jede Datei ihren Inhalt im Namen trägt, lässt sich dagegen als Ganzes beschreiben: unbegrenzt zwischenspeichern, ohne Ausnahme. Das Verzeichnis wird nicht geraten, sondern aus build.assets gelesen.
Warum jede Direktive in einem <IfModule> steht. Ein Header-Aufruf auf einem Server ohne mod_headers beantwortet jede Anfrage mit einem Fehler 500. Nicht ein Header fehlte dann, sondern die Website wäre offline. Ein Test prüft deshalb, dass keine Direktive außerhalb eines Modulblocks steht.
Warum die HTTPS-Weiterleitung zwei Bedingungen prüft. Hinter einem Proxy, der TLS beendet, steht %{HTTPS} auf off, obwohl der Besucher längst über HTTPS kommt. Eine Regel, die nur darauf schaut, leitet endlos im Kreis. Erst X-Forwarded-Proto zusätzlich zu prüfen macht sie richtig — und genau dieser Fall ist bei einem gehosteten statischen Deployment der Regelfall.
Warum nur mod_deflate und nicht auch mod_brotli. Sind beide Filter für denselben Inhaltstyp registriert, komprimiert Apache zweimal, und die Antwort ist unlesbar. Das ist ein Fehler, der nur bei Clients auftritt, die beide Kodierungen anbieten — also bei fast allen, aber nicht bei curl ohne Argumente. Brotli gehört, wenn überhaupt, in die Serverkonfiguration, wo genau eine Stelle zuständig ist. Auch die Referenzkonfiguration von h5bp verwendet ausschließlich mod_deflate.
Warum HSTS nicht voreingestellt ist. Der Header ist eine Zusage an den Browser, diese Domain für die angegebene Dauer ausschließlich über HTTPS zu laden — und die nimmt er nicht zurück, nur weil die Konfiguration sich ändert. Mit includeSubDomains gilt sie zusätzlich für jede Subdomain, auch für die, die es noch nicht gibt. Das ist richtig, wenn man es entschieden hat, und ein Ausfall, wenn man es geerbt hat. In einem Template, das zwanzigmal vervielfältigt wird, ist der Unterschied entscheidend.
Verworfene Alternativen
public/.htaccess. Sie hätte die kanonische Domain, die Weiterleitungen und den CSP-Modus ein zweites Mal enthalten müssen, und die Hashes der Inline-Skripte gar nicht enthalten können. Und sie wäre auf GitHub Pages mitgeliefert worden.
Eine .htaccess im Repository, die der Build nur ergänzt. Die Vorlage und das Ergänzte wären zwei Quellen für dieselbe Datei; bei einem Konflikt gewinnt, wer zuletzt geschrieben hat. Der erzeugte Text sagt dagegen von sich selbst, dass er erzeugt ist.
Die Regeln der Serverkonfiguration überlassen. Sie liegt nicht im Repository, ist nicht versioniert und in jedem Kundenprojekt anders. Die erzeugte Datei nennt für den Fall abgeschalteter AllowOverride in ihrem Kopf, was dann zu tun ist.
Konsequenzen
- Ein Wechsel des Zielsystems ändert die Auslieferung: Bei
github-pagesverschwinden beide Dateien, und die Sicherheits-Header entfallen. Das ist keine Nachlässigkeit, sondern die Eigenschaft der Plattform — die Dokumentation nennt sie beim GitHub-Pages-Deployment. - Die Datei ist bei gleichen Quellen byteweise gleich. Erschien sie bei jedem Deployment als Änderung, würde eine echte Änderung darin untergehen.
- Wer eine Regel braucht, die der Renderer nicht kennt, erweitert
deployment.apacheund den Renderer — nicht die Ausgabe. Eine Änderung andist/.htaccessist beim nächsten Build weg. - Der Handler unter
api/forms/bringt seine eigene.htaccessmit (ADR 0010). Beide bestehen nebeneinander: Die des Handler-Verzeichnisses sperrt es ab, die erzeugte im Wurzelverzeichnis liefert die Sicherheits-Header, die auch für dessen Antworten gelten.