tobias-barth.net

Modernes Webdesign aus Köln

Ezjail und IPv6 alias Adressen

Ich habe vor Kurzem begonnen Jails in FreeBSD zu nutzen und auszuprobieren. Mein Digitalocean-Droplet läuft unter FreeBSD 11 und DO gibt mir eine public v4 IP-Adresse und ein /124 v6 Präfix. Für meinen privaten Kram interessiert mich IPv4 nicht, daher habe ich 16 öffentliche IPs, mit denen ich spielen kann.

Das heißt, ich brauche kein NAT sondern kann den Jails direkt public IPs zuweisen, die ich als Aliases auf das Interface lege.

Bisher habe ich mit ezjail zwei Jails eingrichtet: git und backup. DO weißt dem Interface automatisch die v6-IP mit der Endziffer 1 zu. In meiner rc.conf steht entsprechend für die beiden Jail-IPs:

1
2
3
4
ifconfig_vtnet0_alias1="inet6 xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxx2 prefixlen 64"
ifconfig_vtnet0_alias2="inet6 xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxx3 prefixlen 64"
cloned_interfaces="lo1"
ezjail_enable="YES"

Heute musste ich das Droplet powercyclen und beim Boot kam nur der git-Jail hoch. Ich probierte, den zweiten selbst zu starten:

# ezjail-admin start backup

Das gab diesen Fehler aus:

ifconfig: ioctl (SIOCAIFADDR): Invalid argument)

Erst wusste ich damit nichts anzufangen. Dann fiel mir auf, dass in der Zeile davor der Befehl stand, der versucht wurde auszuführen:

/usr/sbin/ifconfig vtnet0 inet6 <adresse>/128 alias

Hoppala. ezjail versuchte selbst einen Alias anzulegen, obwohl schon einer existiert. Tatsächlich habe ich dann nochmal selbst den ifconfig Befehl getestet, geht tatsächlich nicht.

Ist auch klar: Mit dieser IP war bereits ein Alias definiert mit der Präfixlänge 64. Hier wurde versucht ein Alias für dieselbe IP anzulegen, aber mit der Präfixlänge 128, was natürlich nicht geht.

Ein Blick in die ezjail-Config-files für die beiden Jails zeigt, dass ich bei dem git-Jail die IP so konfiguriert habe:

export jail_git_ip="xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxx2"

Bei dem backup-Jail brauchte ich aber wegen eines Dienstes darin ein Loopback-Interface, das ich in der rc.conf oben als lo1 angelegt habe. Also muss ich dem Jail zwei Interfaces mit IPs übergeben und das mache ich so:

export jail_git_ip="lo1|127.0.0.3,vtnet0|xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxx3"

Offenbar führt die fehlende Präfixlängenangabe hier dazu, dass ezjail (oder ifconfig) sie auf 128 setzt. Also änderte ich die Zeile zu:

export jail_git_ip="lo1|127.0.0.3,vtnet0|xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxx3/64"

Und schon läuft es.

Einfache Foto-Upload-App mit Node.js

Meine Frau brauchte für ein Projekt mit Kindern eine einfache Möglichkeit, Fotos von Smartphones auf ihren Laptop zu laden. Es gibt viele Methoden das zu machen: Bluetooth, USB, SD-Karten-Austausch, und natürlich die üblichen Internet-Lösungen wie Dropbox.

Bei 20 Zehn- bis Vierzehnjährigen sind 1-zu-1-Verbindungen mit Bluetooth oder Kabel nicht so richtig praktisch. Internetbasierte Lösungen waren aus verschiedenen Gründen ebenfalls nicht das Richtige. Also habe ich angeboten, eine kleine Eigenbau-Lösung zu basteln.

Das Konzept: Wir stellen einen WLAN-Router auf, mit dem sich alle verbinden. Er braucht keinen Internet-Uplink. Die Projektleiterin startet einen Node.js-Webserver auf ihrem Macbook, der eine Seite mit einem Webformular ausliefert, das nichts anderes tut, als einen Datei-Upload zu ermöglichen. Der Server speichert die hochgeladenen Files auf dem Laptop und das wars.

Schritt 0: Init

$ mkdir -p upload-app/server && cd upload-app

Wir initialisieren das Projekt und installieren das erste Paket:

$ yarn init -y
$ yarn add express

Schritt 1: Der Server

Als erstes erstellen wir die Datei server/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// index.js
const express = require('express');
const app = express();

// Für CSS und ggfs. JS
app.use(express.static(path.join(__dirname, 'static')));

app.get('/', (req, res) => {
res.send('Hello\n');
});

app.listen(3000, (err) => {
if (err) throw err;
console.log('Listening on port 3000');
});

Sehr schön. Um uns das Entwicklerleben leichter zu machen, installieren wir uns nodemon und starten dann per npm-Script den Server:

$ yarn add -D nodemon
1
2
3
4
5
6
7
8
9
// package.json
{
"name": "upload-app",
"version": "1.0.0",
"scripts": {
"start": "nodemon ./server/index.js"
},

}
$ npm start
Listening on port 3000

Schritt 2: Die Startseite

Bisher antwortet unser Server nur mit dem String 'Hello'. Wir ändern das mit einem schicken Low-Budget-Template. Wir erstellen die Datei server/templates.js mit folgendem Inhalt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// server/templates.js 
const pageHeader = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Du und die Kamera – KKS</title>
</head>
<body>
<div class="wrapper">
`;


const pageFooter = ` </div>
<script src="/upload.js" />
</body>
</html>
`;


const homepage = () => `${pageHeader}
<form action="/upload" method="post" enctype="multipart/form-data">
<h1>Du und die Kamera</h1>
<label for="upload">Bild auswählen</label>
<input id="upload" type="file" name="datei" accept="image/*" />
<button type="submit">Hochladen</button>
</form>
${pageFooter}
`;


module.exports = {
homepage,
};

Wir exportieren also eine Funktion, die ein Template-Literal zurückgibt. Wir hätten Header und Footer auch direkt integrieren können, aber wir brauchen sie später noch für eine zweite Seite.

In server/index.js nutzen wir jetzt die homepage-Funktion:

1
2
3
4
5
6
// server/index.js
const { homepage } = require('./templates.js');

app.get('/', (req, res) => {
res.send(homepage());
});

Wir haben jetzt ein Formular mit einem File-Upload-Input auf unserer Website. Es sieht noch ein bisschen unspektakulär aus. Werfen wir etwas CSS dagegen! Wir erstellen die Datei server/static/style.min.css:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* server/static/style.min.css */ 
* {
font-family: sans-serif;
box-sizing: border-box;
}

img {
max-width: 100%;
}

.wrapper > form {
display: flex;
width: 20em;
max-width: 100%;
margin-left: auto;
margin-right: auto;
justify-content: center;
flex-wrap: wrap;
}

#upload {
display:none;
}

[for="upload"] {
display: block;
width: 18em;
padding: 1em;
margin-top: 1em;
margin-bottom: 2em;
border-radius: .2em;
text-align: center;
color: white;
background-color: deepskyblue;
font-weight: bold;
}

Besser. Anmerkung: Ich blende hier das eigentliche Input-Element aus und nutze die Tatsache, dass man auch auf dass zugehörige Label-Element klicken kann, um den Datei-Auswahl-Dialog zu öffnen. Diese Idee habe ich mir aus dem MDN abgeguckt: Using a label element to trigger a hidden file input element. Das hat den Vorteil, dass man das hässliche Browser-gestylte File-Input los ist und in Ruhe einfach das Label stylen kann.

Schritt 3: Datei-Uploads annehmen

Jetzt sollten wir dafür sorgen, dass der /upload Pfad auch in der Express-App definiert ist und die hochgeladenen Files gespeichtert werden.

Wir benutzen dafür multiparty:

$ yarn add multiparty

und passen zuerst unseren Server an:

1
2
3
4
// in server/index.js
const handleUpload = require('./handleUpload.js');

app.post('/upload', handleUpload);

Die Datei server/handleUpload.js müssen wir natürlich auch schreiben:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// server/handleUpload.js
const Form = require('multiparty').Form;
const { uploadOptions } = require('./config.js');
const { successPage } = require('./templates.js');

module.exports = function handleUpload(req, res) {
const form = new Form(uploadOptions);
form.on('file', (name, file) => {
req.filename = file.originalFilename;
});
form.on('error', () => {
});
form.on('close', () => res.send(successPage(req.filename)));
form.parse(req);
};

Die Instanzen von multiparty.Form sind Event-Emitter. .parse verabeitet die Anfrage vom Browser, in der die hochgeladene Datei enthalten ist. Das file-Event wird emittiert, wenn eine Datei aus dem Request fertig verarbeitet ist. Hier nutzen wir die Gelegenheit um den Original-Dateinamen im Request-Objekt zwischen zu speichern.

Die Dokumentation von multiparty rät dringend, einen Error-Listener zu registrieren, auch wenn man nichts mit dem Fehler macht. Ansonsten crasht nämlich die App bei allem, was multiparty als Error ansieht.

Schließlich senden wir eine Erfolgsmeldung an den Browser zurück, wenn der Request fertig verarbeitet ist. Diese successPage ist wieder eine Template-Funktion, die wir genau wie die Homepage in server/templates.js definieren:

1
2
3
4
5
6
7
8
9
10
11
// in server/templates.js

const successPage = filename => `${pageHeader}
<p>Du hast "${filename}" erfolgreich hochgeladen.</p>
${pageFooter}
`;


module.exports = {
homepage,
successPage,
};

Hier nutzen wir den im Request-Objekt gespeicherten Dateinamen, um der Nutzerin im Browser nochmal anzuzeigen, was sie hochgeladen hat.

Haben alle bemerkt, dass es noch ein Detail in handleUpload.js zu besprechen gibt? config.js, richtig.

Der Bequemlichkeit halber legen wir die Datei server/config.js an, und exportieren von dort ein paar Dinge:

1
2
3
4
5
6
7
8
9
10
11
12
// server/config.js
const path = require('path');

const uploadDir = path.join(process.cwd(), 'upload-app');

module.exports = {
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
uploadDir,
uploadOptions: {
uploadDir,
},
};

Bisher geben wir multiparty nur eine Option mit, aber eventuell ändert sich das auch einmal und dann haben wir schon ein ganzes Config-Objekt dafür. Außerdem wollen wir in der Lage sein, im Produktions-Modus den allgemein gültigen HTTP-Port zu nutzen anstatt 3000. Das uploadDir exportieren wir gleich mit, um es bei App-Start anzulegen und zusammen mit der “Listening …”-Meldung anzuzeigen. So weiß derjenige, der den Server startet, auch direkt, wo er nach den hochgeladenen Dateien schauen muss. Wir haben es hier so eingerichtet, dass dieses Verzeichnis innerhalb des Ordners erzeugt wird, aus dem die App gestartet wird. Das ganze wird von unserem Server so genutzt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// in server/index.js
const fs = require('fs');
const { port, uploadDir } = require('./config.js');

try {
fs.mkdirSync(uploadDir);
} catch (e) {
// Wenn das Verzeichnis schon existiert, machen wir einfach weiter.
// Bei jedem anderen Fehler lassen wir crashen.
if (e.code !== 'EEXIST') throw e;
}
app.listen(port, (err) => {
if (err) throw err;
console.log(`
Listening on port ${port}
Using directory "${uploadDir}" for uploads
`);

});

Damit sind wir eigentlich fertig. Wir haben eine Website, man kann dort eine Bild-Datei hochladen, sie landet in unserem Upload-Ordner und man bekommt eine Bestätigung nach dem Hochladen.

Schritt 4: Es geht besser

Es bleibt allerdings die Frage offen: Woher wissen denn alle Beteiligten, was sie in die Adresszeile ihres Smartphone-Browsers eingeben müssen, um zu unserer schicken Upload-App zu kommen?

Per default lauscht Express auf allen zugewiesenen IP-Adressen. Wenn wir wüssten, welche IP der Computer, auf dem der Server läuft, vom WLAN-Accesspoint bekommen hat, könnten wir das allen sagen. Wir wollen die Kursleiterin aber nicht dazu verdonnern in ihren Netzwerkeinstellungen irgendwo in den Untiefen des Computers danach zu suchen. Es wäre doch schon mal viel netter, wenn wir die richtige (also erreichbare) IP einfach direkt beim App-Start im Terminal ausgeben. Dann könnte zumindest die Kursleiterin allen die Addresse sagen.

Ok, los gehts. Wir brauchen eine IP-Adresse, die nicht intern ist (intern wäre z.B. 127.0.0.1, die zeigt immer auf den Rechner, auf dem sie angefragt wird). Dann stellen wir sicher, dass der Server auf dieser Adresse lauscht und zeigen sie im Terminal an:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// in server/config.js
const os = require('os');

const getMachineIp = () => {
const ifs = os.networkInterfaces();
return Object.keys(ifs)
// Accumulate all address objects from all interfaces in one array
.reduce((flattened, iface) => [...flattened, ...ifs[iface]], [])
// Only external addresses
.filter(address => !address.internal)
// Only v4 addresses (easier to type in a browser)
// And take only the first one
.filter(address => address.family === 'IPv4')[0].address;
};

module.exports = {
port: process.env.NODE_ENV === 'production' ? 80 : 3000,
uploadDir,
uploadOptions: {
uploadDir,
},
serverIp: getMachineIp(),
};

Ich empfehle jedem mal auf dem eigenen Rechner die Node.js REPL zu starten und dort os.networkInterfaces() aufzurufen, um einen Eindruck zu bekommen, mit was für Daten wir hier arbeiten.

Als nächstes bauen wir das in den Server ein:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// in server/index.js
const { port, serverIp, uploadDir } = require('./config.js');

if (serverIp) {
try {
fs.mkdirSync(uploadDir);
} catch (e) {
if (e.code !== 'EEXIST') throw e;
}
app.listen(port, serverIp, (err) => {
if (err) throw err;
console.log(`
Listening on ${serverIp}:${port}
Using directory "${uploadDir}" for uploads
`);

});
});
} else {
throw Error('No public v4 IP found!');
}

Wir können jetzt zwar unsere Website nicht mehr mit localhost:3000 aufrufen, aber das wäre ja eh nur auf dem Computer gegangen, auf dem der Server läuft. Viel wichtiger ist, dass die Kinder sie von außen erreichen.

Jetzt könnte die Kursleiterin also die richtige IP-Adresse sehen und sie z.B. an die Tafel schreiben. Oder in ein Dokument tippen und das mit dem Beamer an die Wand werfen. Moment. Ein Beamer? Ein Dokument? Wir können die IP auch gleich zusätzlich auf der Upload-Webseite anzeigen. Dazu müssen wir sie bloß unserer Template-Funktion übergeben:

1
2
3
4
5
6
7
8
9
10
11
// in server/templates.js
const homepage = ({ address, listenPort }) => `${pageHeader}
<form action="/upload" method="post" enctype="multipart/form-data">
<h1>Du und die Kamera</h1>
<p>Die Adresse ist: ${address}${listenPort === 80 ? '' : `:${listenPort}`}</p>
<label for="upload">Bild auswählen</label>
<input id="upload" type="file" name="datei" accept="image/*" />
<button type="submit">Hochladen</button>
</form>
${pageFooter}
`;
1
2
3
4
5
6
7
8
9
10
// in server/index.js

const homepageOpts = {
address: serverIp,
listenPort: port,
};

app.get('/', (req, res) => {
res.send(homepage(homepageOpts));
});

Schritt 5: Und noch besser

IP-Adressen sind trotzdem ganz schön nervig einzutippen, erst recht auf einem Smartphone. Also machen wir noch eine Verbesserung: Einen QR-Code!

$ yarn add qrcode

Wir erzeugen jetzt beim App-Start einmalig aus der IP-Adresse und dem Port einen URL, den wir als QR-Code in Form eines Image-Data-URI encodieren. Das spart uns das abspeichern, auslesen und aufräumen einer richtigen Bild-Datei. Der Data-URI wird bei App-Start erzeugt und im RAM gehalten bis der Prozess beendet wird. Und wir können ihn einfach bei jedem Request an die Template-Funktion weiterreichen, von der er direkt in das HTML injiziert wird. Sollte aus irgendeinem Grund das Erzeugen fehlschlagen, ist uns das egal, denn die IP wird als Fallback auch noch angezeigt.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// in server/index.js
const qrcode = require('qrcode');

app.get('/', (req, res) => {
homepageOpts.qr = app.locals.qr;
res.send(homepage(homepageOpts));
});

if (serverIp) {
try {
fs.mkdirSync(uploadDir);
} catch (e) {
if (e.code !== 'EEXIST') throw e;
}
const location = `${serverIp}${port === '80' ? '' : `:${port}`}`;
qrcode.toDataURI(`http://${location}`, { scale: 8 }, (error, uri) => {

app.locals.qr = uri;
app.listen(port, serverIp, (err) => {
if (err) throw err;
// eslint-disable-next-line no-console
console.log(`
Listening on ${serverIp}:${port}
Using directory "${uploadDir}" for uploads
`);

});
});
} else {
throw Error('No public v4 IP found!');
}
1
2
3
4
5
6
7
8
9
10
11
12
// in server/templates.js
const homepage = ({ address, listenPort, qr }) => `${pageHeader}
<form action="/upload" method="post" enctype="multipart/form-data">
<h1>Du und die Kamera</h1>
<p>Die Adresse ist: ${address}${listenPort === 80 ? '' : `:${listenPort}`}</p>
${qr ? `<p><img src="${qr}" /></p>` : ''}
<label for="upload">Bild auswählen</label>
<input id="upload" type="file" name="datei" accept="image/*" />
<button type="submit">Hochladen</button>
</form>
${pageFooter}
`;

So, jetzt sind wir aber wirklich fertig. Übrigens ganz ohne Client-Side-Javascript. Wir brauchten gerade mal zwei Dependencies: express und multiparty. Eine dritte, qrcode, um den Bequemlichkeitsfaktor noch zu erhöhen. Das ganze läuft unter Node.js 6.9.5, ohne Babel, ohne Webpack, ohne React, sogar ganz ohne externe Templating-Engine.

Was noch schön wäre, weil wir ja das File-Input-Element kaputtgemacht haben, mit ein bisschen Browser-Javascript das zum Upload ausgewählte Bild anzuzeigen. Entweder als schnöden Dateinamen oder sogar als Thumbnail. Ideen dazu gibt es hier.

Außerdem wollen wir eher ungern die Kursleiterin dazu zwingen, sich Node.js zu installieren, npm install zu machen und so weiter. Daher bietet sich ein Packager wie pkg von zeit an.

Node.js Performance und socket hangup

In dem Team bei einer großen deutschen Internetfirma, in dem ich gerade arbeite, haben wir ein bisher eher unerklärliches Problem, das in unseren Logfiles aufschlägt.

Wir haben dort eine Express-basierte Webapp, die den Content im Grunde vollständig auf dem Server rendert. Dafür werden unter anderem diverse Microservices angesprochen, d.h. bei jedem Request an die Express-App fragt diese per AJAX mehrere interne, separate Dienste nach Daten und rendert basierend auf deren Antworten den Content, der an den Client gesendet wird.

Das funkioniert auch ziemlich gut, allerdings haben wir in den Logfiles unserer App ungewöhnlich viele “ERROR: socket hang up”-Meldungen. Nicht so viele, dass wir ernsthafte Schwierigkeiten hätten, die auch sichtbar wären, aber, und das ist besonders interessant, es sind wesentlich mehr als bei anderen Webapps, die Java-basiert sind und dieselben Services bei jedem Request anfragen.

Es scheint also primär nichts mit den internen Services zu tun zu haben, sondern mit unserer Node-App. Bisher haben wir nicht so wirklich eine Idee, was es sein könnte. Für die AJAX-calls nutzen wir Axios und definieren damit auch ein Timeout, das bei 60ms liegt. Das kann es allerdings nicht sein, denn dann sähen wir timeout-Errors und nicht Socket-hang-ups.

Also habe ich mal das Internet danach durchforstet, was diese Socket-hangups bei Node erzeugen könnte. Dabei bin ich auf einen interessanten Blogpost gestoßen. Dort wird beschrieben, dass solche Fehler zwar auch von der Remote-Seite kommen können, aber eben auch von den Limitierungen des Systems, auf dem der Node-Server läuft. Es gibt ein Limit für die Zahl der gleichzeitig offenen Files, wobei dieses Limit pro Login=User gilt. Der Standardwert ist 1024. Ein Socket ist wie alles auf unixoiden Systemen eine Datei. Ein User (in dem Fall der User, dem der Node-Prozess gehört), kann also maximal 1024 Dateien gleichzeitig öffnen.

Das wäre auf jeden Fall mal ein Ansatz. Der Verfasser des erwähnten Blogartikels empfiehlt ein Limit von 10240, also das Zehnfache. Außerdem sollte der maxSockets-Wert des http.Agents von Node auf eine Zahl knapp unter dem ulimit gesetzt werden. Nodes Standard für maxSockets ist (mittlerweile) Infinity, aber bei uns ist es derzeit auf 25 gesetzt. Ich muss noch mal nachforschen, was der Grund für dieses Limit war.

Ohne dass ich weiß, wie unsere Java-Apps funktionieren oder konfiguriert sind, gefällt mir der Gedanke, dass unsere Node.js-App ankommende Anfragen so schnell verarbeitet und dementsprechend oft parallel die Services anspricht, dass es an Systemlimits stößt, die von den Java-Kreuzern nie tangiert werden.

Schauen wir mal.

Mein Blog

Endlich habe ich es doch geschafft. Mein Blog ist funktionsfähig und online.

Ich hatte ja schon im zugehörigen Github-Issue geschrieben, dass ich mich für das Hexo-Framework entschieden hatte. Ich fand es spannend, ein bisschen ungewöhnlich und natürlich toll, dass es in Nodejs geschrieben ist. So kann ich schnell Dinge ändern bzw. fixen, die mich stören.

Das erste habe ich schon gefixt, nämlich das hexo-generator-feed-Plugin. Das erzeugte in meiner Konfiguration (also Hexo-Blog in Unterordner) falsche Permalinks. Ich habe den Autor zwar unter dem betreffenden Commit gefragt, wofür der war (er hat es kaputt geändert), aber bisher keine Antwort. Ich werd’s wohl demnächst auch mal als Bug oder gleich als Pull Request filen. Die reparierte Version findet man zur Zeit bei mir: 4ndurils hexo-generator-feed.

Ich habe zwar schon länger ein privates Blog gehabt, in dem auch hin und wieder Web-Kram landete, aber ich wollte jetzt gern im richtigen Rahmen und öfter Sachen aus dem Frontend-Alltag (oder Säuremiene, wie manche sagen) posten. Zwei, drei alte Posts vom anderen Blog habe ich hier der Vollständigkeit halber importiert, aber die sind wirklich schon betagt.

Achso, zwei Dinge noch:

  1. Das hier ist nur die minimalist working version. Es gibt z.B. noch keine vernünftige Integration von Kategorien und Tags (sie werden zumindest noch nicht dargestellt). Das wird sich über die Zeit aber immer weiter verbessern. Die Grundfunktion eines Blogs sind Einträge und der Feed. Beides geht.
  2. Es gibt keine Kommentarsektion. Das ist auch Absicht und wird erstmal so bleiben. Ob ich meine Meinung ändere, weiß ich noch nicht, derzeit steht sie aber fest. Kommentare und Anmerkungen erhalte ich aber trotzdem sehr gern per Twitter oder Email.

Viel Spaß. Ich freu mich!

Vererbung von viewport-percentage lengths in Chrome

Ich stolperte neulich über ein Problem mit viewport-relativen Längen in CSS. Die Situation war konkret folgende:

Ich hatte eine ungeordnete Liste, deren Elemente je ein <div> enthielten, in dem sich wiederum ein <a> befand:

1
2
3
4
5
<ul>
<li>
<div><a href="#">Test</a></div>
</li>
</ul>

Zuerst ein Beispiel, wie es funktionieren sollte. Die <li>-Elemente sollen eine definierte Höhe haben und die <div>s sollten genauso hoch sein. Das CSS dazu:

1
2
3
4
5
6
7
8
9
10
* { margin:0; padding:0; }
ul { list-style-type:none; }
li {
background:blue;
height:10em;
}
div {
background:red;
height:100%; /* Genau so hoch wie sein Container */
}

Hier ist ein JS-Fiddle dazu. Wie erwartet bedeckt das rote <div> das gesamte Listenelement. Ruhig auch mal in verschiedenen Browsern ansehen.

Jetzt geben wir dem <li>-Element aber eine vom Viewport abhängige Größe:

1
2
3
li {
height:30vw; /* Die Höhe soll 30 Prozent der Breite des Viewports betragen */
}

Hier die geänderte Demo.

Im Firefox sieht es immer noch genau so aus wie vorher. Das würde man (ich) auch erwarten. Schließlich hat das Listenelement eine definierte Größe und ich sage, dass sein direktes Kindelement 100% dieser Größe haben soll. Sieht man sich das Ergebnis in Chrome an, zeigt sich aber ein anderes Bild.

Das <li> hat zwar die richtige Größe, aber das <div> ist nur so hoch, wie es sein Inhalt (eine Textzeile) erfordert. Sogar im IE9 wird es korrekt (wie im FF) dargestellt. Opera unterstützt derzeit keine viewport-related lengths, aber da auch dort demnächst ein Webkit rendert, wird es sich wohl auch nur mittelmäßig zum Guten ändern.

Anscheinend muss man derzeit also entweder für Chrome in solchen Fällen auch jedem Kindelement die v*-Größe zuweisen, oder mit anderen Längeneinheiten arbeiten. Schade.

jQuerys scrollTop() und border-box

Das Folgende betrifft soweit ich sehe nur jQuery < 1.8.

Letzte Woche habe ich den ersten Kandidaten für den irrsten Bug in diesem Jahr gefunden.

Der Fehler, den der Kunde berichtete, beruhte darauf, dass das ursprünglich verwendete $(document).scrollTop() nicht im IE8 funktionierte und nachdem er dies durch das funktionierende $('html').scrollTop() ersetzt hatte, lief es nicht mehr in Webkit-Browsern.

Die Lösung dafür war relativ schnell gefunden: Ich ersetzte einfach in dem betreffenden Script den einen scrollTop()-Aufruf durch den Ausdruck ($(document).scrollTop() || $('html').scrollTop()). Damit war der Drops gelutscht und jeder Browser konnte sich aussuchen, was ihm passte (bzw. was nicht gleich 0 war, da der Code nur beim Scrollen zur Anwendung kam, reichte das aus).

Aber was war die Ursache? Nach viel Ausprobieren blieb mir nichts übrig als in die jQuery-Source zu schauen und mir anzusehen, wie jQuery.scrollTop() definiert ist. Für das verwendete jQuery 1.6.4 sieht das so aus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Create scrollLeft and scrollTop methods
jQuery.each( ["Left", "Top"], function( i, name ) {
var method = "scroll" + name;

jQuery.fn[ method ] = function( val ) {
var elem, win;

if ( val === undefined ) {
elem = this[ 0 ];

if ( !elem ) {
return null;
}

win = getWindow( elem );

// Return the scroll offset
return win ? ("pageXOffset" in win) ? win[ i ? "pageYOffset" : "pageXOffset" ] :
jQuery.support.boxModel &amp;&amp; win.document.documentElement[ method ] ||
win.document.body[ method ] :
elem[ method ];
}

// Set the scroll offset

// Interessiert uns hier nicht
};
});

Der Fall des IE8 sollte mit der Zeile abgedeckt werden, die mit jQuery.support.boxModel beginnt. Diese Eigenschaft dient eigentlich dazu, zu überprüfen, ob das W3C-Box-Modell vom Browser unterstützt wird. Das Problem ist, in diesem Fall gibt sie false zurück, obwohl das Dokument in Ordnung ist und der Browser sich im Standardmodus befindet. Warum? Also nachsehen, wie jQuery.support.boxModel gesetzt wird:

1
2
3
// Figure out if the W3C box model works as expected
div.style.width = div.style.paddingLeft = "1px";
support.boxModel = div.offsetWidth === 2;

Es wird ein Test-Div erzeugt, dem verschiedene Eigenschaften zugewiesen und das dann an den body angehängt wird. Da das Element hier einen Pixel breit ist und außerdem ein Padding von einem Pixel erhält, sollte offsetWidth, das die Gesamtbreite enthält, 2px zurückgeben.

Tut es aber nicht.

Der Grund dafür ist, dass ich im CSS box-sizing: border-box; gesetzt habe. Das ist eigentlich eine prima Sache, denn dadurch sind Elemente, denen man eine bestimmte Breite zuweist, auch wirklich so breit – egal ob sie Innenabstände oder Rahmen enthalten. Aber in diesem Fall passiert dann folgendes: Wir geben einem Element den linken Innenabstand 1px und sagen dann, dass das gesamte Element, mit Innenabständen und Rahmen, 1px breit sein soll. Das bedeutet, für den eigentlichen Inhalt ist kein Platz mehr, was egal ist, weil das Element sowieso keinen Inhalt hat und nur zum Testen da ist. Aber das bedeutet auch, dass offsetWidth nun auch 1 enthält, das ist schließlich die Gesamtbreite des Elements. Damit schlägt der Test div.offsetWidth === 2 natürlich fehl, und scrollTop() gibt nicht mehr window.document.documentElement.scrollTop zurück, sondern 0.

Darauf muss man erstmal kommen

Ab jQuery 1.8 besteht das Problem nicht mehr, weil dann nicht mehr auf Box-Model-Support getestet wird bzw. dieser Test nicht mehr in scrollTop() abgerufen wird.

Warum Webkit übrigens $('html').scrollTop() nicht versteht, ist mir noch nicht so ganz klar.

.htaccess-Spielereien

Mit .htaccess-Dateien kann man den Zugang zu den einzelnen Dateien oder Dokumenten auf seinem (Apache-)Webserver sehr bequem regeln. Es lassen sich zum Beispiel einzelne Verzeichnisse nur für Nutzer mit einer bestimmten IP freigeben. Oder man kann Passwörter für den Zugang vergeben. Vor ein paar Tagen habe ich herausgefunden, dass man mit ihnen auch wunderbar beliebige andere Servervariablen abfragen kann.

Mit dem Modul mod_setenvif nämlich lassen sich mit Hilfe der zwei Direktiven BrowserMatch und SetEnvIf (und deren Varianten, denen Groß- und Kleinschreibung egal ist) Umgebungsvariablen abfragen und davon abhängig den Zugang zu Ressourcen regeln.

Ich habe das benutzt, um folgendes zu tun. Ein PHP-Dokument bindet mit require eine HTML-Datei ein und zwar abhängig vom Rückgabewert eines If-Statements:

1
if (…) { require "eingebunden.html";}

Auch wenn ich den Namen der HTML-Datei so wähle, dass er schwer erraten werden kann, möchte ich sichergehen, dass niemand einfach die Adresse der Datei in den Browser eingeben und so ihren Inhalt anzeigen kann. Das geht jetzt ziemlich einfach, indem ich eine .htaccess-Datei in das Verzeichnis lege, in dem sich die fragliche HTML-Datei befindet und sie mit diesem Inhalt versehe:

1
2
SetEnvIf Request_URI "eingebunden\.html$" verboten
Deny from env=verboten

Mit der SetEnvIf-Direktive setze ich eine Variable namens „verboten“ genau dann wenn der URI-String, den der Browser als Request gesendet hat, mit dem regulären Ausdruck "eingebunden\.html$" übereinstimmt. Als nächstes sage ich dem Server, dass er Anfragen, für die er diese Variable erzeugt hat, ablehnen soll. Versucht man jetzt die HTML-Datei direkt im Browser aufzurufen, ist das einzige, was man sieht, ein 403.

Flattr this