Um seine Fotos schnell über einen privaten Link zum Teilen im Web anzubieten, schreibt Mike Schilli ein Go-Programm, dessen Template-Engine auch die Link-Vorschau im Messenger beherrscht.
Zeige ich frisch geschossene und gut belichtete Digitalfotos herum, heißt es oft: “Kannst mir das auch gleich schicken?” Als gute Haut, die ich bin, lasse ich mich nicht lange bitten. Handelt es sich um mehrere Fotos, nach denen mehrere Leute lechzen, ist es einfacher, die Kollektion ins Web zu stellen und einen Link darauf zu teilen, auf dass die gierigen Empfänger sich selbst bedienen können.
Diese Snapshot-Folge zeigt ein CGI-Skript »photoup«, das es dem Admin erlaubt, eines oder mehrere Fotos auf einen Webserver hochzuladen. Dort landen sie in einem Verzeichnis mit schwer zu erratendem Namen. Es enthält zudem ein Layout, das die Fotos als Set oder in der Einzelansicht zeigt, und zwar bildschön, egal ob auf dem Desktop oder dem Smartphone. Das Ganze ist in Go umgesetzt, mit einer Einführung in Gos Template-Engine, denn das Layout setzt sich aus einzelnen Snippets zusammen, die das fertige Programm als fertige HTML-Seite ausspuckt. Gleichzeitig bindet das Skript mittels Template-Schleifen noch dynamisch Fotos ein. Das reinste Hexenwerk!
Um die während einer Flugreise aufgenommenen Fotos zu teilen, lade ich sie mit dem Browser (Abbildung 1) des Smartphones hoch (Abbildung 2). Der neu generierte Link (Abbildung 3) führt anschließend zu einem Kontaktabzug (Abbildung 4). Ein Mausklick auf ein Bild bringt die JPEG-Datei mit der vollen Auflösung hoch (Abbildung 5), damit Freunde sie nach Herzenslust herunterladen können.

Abbildung 5: Ein Klick auf ein Bild bringt die volle Auflösung (oben) und zoomt auf Wunsch hinein (unten).
GET oder POST
Das Hochladen der Fotos erledigt das CGI-Programm aus Listing 1. Beim ersten Aufruf zeigt der Browser im Smartphone oder auf dem Desktop das Formular aus Abbildung 1 an. Klickt der Nutzer auf Choose Files, fragt der aufpoppende Dialog nach Dateien, entweder aus der Fotosammlung des Smartphones oder einem voreingestellten Ordner auf dem Desktop. Nach der Bestätigung der Auswahl einer oder mehrerer Dateien veranlasst ein Klick auf den Button Upload des Formulars den Browser dazu, dieselbe URL anzufordern, was noch einmal das CGI-Programm auf dem Server startet.
Bei jedem Aufruf wirft Listing 1 in Zeile 12 den CGI-Handler ab Zeile 14 an. Wurde dieser wie beim vorher erläuterten Erstkontakt mit der HTTP-Methode »GET« aufgerufen, malt Zeile 21 das Upload-Formular in den Browser. Drückt der User aber Upload, nutzt der Browser die Methode »POST«. Zeile 23 ruft dann die Funktion »processUpload()« auf, die ab Zeile 32 definiert ist. Der Browser hat die ausgewählten Dateien mitgeschickt und »ParseMultipartForm()« dekodiert die eingewickelten Dateinamen sowie die zugehörigen JPEG-Daten der Fotos.
Für jede im Upload gefundene Datei ruft Zeile 45 die Funktion »processFile()« ab Zeile 66 auf. Nach der Reinigung des vorgegebenen Namens mit »sanitizeFileName()« (schließlich darf der Server dem Client nicht trauen) speichert das CGI-Programm die Daten unter dem vorher mit »uploadDir()« neu erzeugten Verzeichnis ab. Dieser Pfad ist öffentlich zugänglich, aber schwer zu erraten (dazu später mehr), also können Bekannte die Fotos später von dort als statische Dateien des Webservers herunterladen.
Listing 1
photoup.go
package main
import (
"io"
"mime/multipart"
"net/http"
"net/http/cgi"
"os"
"path"
"regexp"
)
func main() {
cgi.Serve(http.HandlerFunc(uploadHandler))
}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
tmpl := NewTmpl()
err := tmpl.Init()
if err != nil {
panic(err)
}
if r.Method == "GET" {
tmpl.RenderPage(w, "upload.html")
} else if r.Method == "POST" {
link, err := processUpload(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl.Link = link
tmpl.RenderPage(w, "done.html")
}
}
func processUpload(w http.ResponseWriter, r *http.Request) (string, error) {
err := r.ParseMultipartForm(10 << 20) // 10 MB limit
if err != nil {
return "", err
}
files := r.MultipartForm.File["files"]
dir, err := uploadDir()
if err != nil {
return "", err
}
link := regexp.MustCompile(`/[^/]+/[^/]+$`).FindString(dir)
names := []string{}
for _, fh := range files {
name, err := processFile(fh, dir)
if err != nil {
return "", err
}
names = append(names, name)
}
idx, err := os.OpenFile(path.Join(dir, "index.html"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return "", err
}
defer idx.Close()
tpl := NewTmpl()
if err = tpl.Init(); err != nil {
return "", err
}
for _, name := range names {
tpl.AddPhoto(name)
}
tpl.RenderPage(idx, "photos.html")
return link, nil
}
func processFile(fh *multipart.FileHeader, dir string) (string, error) {
file, err := fh.Open()
if err != nil {
return "", err
}
defer file.Close()
fileName := fh.Filename
savePath := path.Join(dir, sanitizeFileName(fileName))
outFile, err := os.Create(savePath)
if err != nil {
return "", err
}
defer outFile.Close()
if _, err = io.Copy(outFile, file); err != nil {
return "", err
}
if err = scaleJPG(savePath); err != nil {
return "", err
}
return fileName, nil
}
func sanitizeFileName(fileName string) string {
allowed := regexp.MustCompile(`[^a-zA-Z0-9_\-\.]+`)
return allowed.ReplaceAllString(fileName, "")
}
Regex ohne Perl
Zeile 42 in Listing 1 muss aus dem Pfad zum Upload-Verzeichnis die letzten zwei Teilstücke extrahieren, um einen Link auf dem Webserver daraus zu generieren. Die letzten zwei Teilstrecken aus einem Pfad wie »/foo/bar/baz/« herauszuholen, ließe sich in Perl mit sogenannten Non-greedy Matches erledigen. Während ».*/« zum Beispiel immer gierig alle Zeichen eines Strings bis zum letzten »/« aufschnappt (einschließlich weiterer Slashes innerhalb des Strings), begnügt sich ».*?/« mit allen Zeichen bis zum ersten Querstrich. Gos Regex-Engine implementiert allerdings nicht den vollen PCRE-Standard, und so hangelt sich Zeile 42 mittels »/[^/]+/[^/]+$« jeweils über die nächsten Nicht-Querstriche von einem Querstrich bis zum nächsten.
Zwei Ansätze
Bei dynamisch aufbereiteten Webseiten teilen sich die Meinungen: Die Jünger von PHP setzen auf funktionsreiche Programmiersprachen, die innerhalb des HTML-Codes bei der Auslieferung auf Veranlassung des Servers Code ausführen, der innerhalb der HTML-Tags Ausgaben produziert. Im anderen Lager sitzen die Template-Anhänger mit Aufbereitungsprogrammen, die Vorlagen aus einem HTML-Gerüst mit Inhalt füllen. Dazu besetzen sie einfache Template-Variablen im Layout mit aktuellen Werten. Das Ergebnis speichern sie als statisches HTML ab, das der Server dann entsprechend schneller ausliefert.
Ob lieber ein Programm ein Template mit Variablen füllen soll oder das Template von sich aus Code startet, der die Lücken füllt, das ist letztlich Geschmackssache. Im ersten Fall bleibt die Programmlogik innerhalb des Layouts simpel. Template-Engines bieten bewusst nur eingeschränkte Textersetzung. Vielleicht noch eine For-Schleife, um Listen darzustellen, aber das war es dann auch schon: Schließlich sollen Code und Layout getrennt bleiben.
Kein Schnickschnack
Go bietet in seiner Standard-Library das Paket text/template, das typische Template-Aufgaben wie Textersatz effizient löst. Listing 2 verpackt die Template-Engine in ein objektorientiertes Paket mit einem Konstruktor und einer »Init()«-Methode, die ein Verzeichnis nach Templates durchsucht und diese für später aufgabelt.
So liegen alle HTML-Snippets für das Layout der an den Browser zurückgeschickten Webseiten im Unterverzeichnis »tmpl/«, und die Funktion »ParseGlob()« in Zeile 27 von Listing 2 liest alle dort gefundenen Dateien mit der Endung ».html« ein. Die Snippets in Listing 3 bis Listing 7 zeigen die Templates, die Platzhalter im Format »{{.Variable}}« enthalten. Die ersetzt die Template-Engine mit den aktuellen Werten gleichnamiger Felder einer der Engine zur Laufzeit überreichten Struktur.
Einzig das Snippet mit den Fotos in Listing 4 nutzt eine Funktion des template-Pakets, die über reine Variableninterpolation hinausgeht. Die Funktion »range()« ab Zeile 2 iteriert über die Variable »Photos«, die ein Array-Slice mit »Photo«-Strukturen erhält. Die einzelnen Elemente bieten ihrerseits entsprechend der Strukturdefinition ab Zeile 7 in Listing 2 die Felder »Path« und »Thumb«, die der HTML-Anker entsprechend referenziert.
Soll die Engine ein Template vom Stapel lassen, nimmt die Methode »Execute()« des template-Pakets eine Struktur entgegen, die in ihren Feldern aktuelle Werte für die Template-Variablen enthält. Als weiterer Parameter dient ein Writer-Objekt, in das die Engine das Ergebnis schreibt. Die Methode erwartet jedoch nur den einfachen Dateinamen (zum Beispiel »intro.html«) und nicht den vollständigen Pfad, um das gewünschte Template aus dem vorher per »ParseGlob()« eingelesenen Satz zu extrahieren.
Egal, ob das darzustellende Template nun »upload.html« oder »photos.html« heißt, umrahmt Zeile 38 in Listing 2 es mit »intro.html« und »outro.html«, damit auch schönes HTML mit Anfang und Ende herauskommt. Von »outro.html« stammt lediglich das abschließende »/body«-Tag.
Listing 2
tmpl.go
package main
import (
"io"
tmpl "text/template"
"time"
)
type Photo struct {
Path string
Thumb string
}
type TmplData struct {
CGI string
TmplEngine *tmpl.Template
Date string
OgDesc string
OgImage string
Photos []Photo
Link string
}
func NewTmpl() *TmplData {
return &TmplData{}
}
func (td *TmplData) Init() error {
td.Date = time.Now().Format("2006-01-02")
td.OgDesc = "Photos uploaded"
td.CGI = "/cgi/photoup"
te, err := tmpl.ParseGlob("tmpl/*.html")
td.TmplEngine = te
return err
}
func (td *TmplData) AddPhoto(name string) {
td.Photos = append(td.Photos, Photo{Path: name, Thumb: thumbName(name)})
}
func (td *TmplData) RenderPage(w io.Writer, tmplName string) error {
if len(td.Photos) != 0 {
td.OgImage = td.Photos[0].Thumb
}
for _, tpl := range []string{"intro.html", tmplName, "outro.html"} {
err := td.TmplEngine.ExecuteTemplate(w, tpl, td)
if err != nil {
return err
}
}
return nil
}
Listing 3
intro.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="og:title" content="Photoup">
<meta property="og:description" content="{{.OgDesc}}">
<meta property="og:image" content="{{.OgImage}}">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav class="navbar">
<a href="../index.html">Photoup</a>
<a href="#foobar">{{.Date}}</a>
</nav>
Listing 4
photos.html
<div class="photo-gallery">
{{range .Photos}}
<div class='photo'> <a HREF={{.Path}}><img src={{.Thumb}}></img></a></div>\n
{{end}}
</div>
Listing 5
upload.html
<BR>
<form enctype="multipart/form-data" action="{{.CGI}}" method="post">
<input type="file" name="files" multiple>
<input type="submit" value="Upload">
</form>
Listing 6
done.html
<P>
Upload complete. <A HREF="{{.Link}}">Share this link</A>
Vorschau aktivieren
Beim Blick auf das Template in Listing 3 fallen ein paar ungewöhnliche Tags wie »og:title« oder »og:image« im HTML-Header auf. Des Rätsels Lösung: Verschickt man einen Link über die Apps iMessage oder Whatsapp, zeigt der Client manchmal eine Vorschau an (Abbildung 6). Bei Links zu Artikeln in Nachrichtenmagazinen ist das oft ein kleines Bildchen, kombiniert mit der Schlagzeile sowie einigen Zeilen des Texts. Darauf klickt der Gesprächsteilnehmer dann neugierig, während er bloße Links oft übersieht oder in unserer kurzatmigen Ära aus Zeitgründen gar ignoriert. Doch bei manchen Webseiten kommt gar keine Vorschau. Bei anderen funktioniert es manchmal und manchmal nicht, was vom Tempo der Serverantwort abzuhängen scheint. Was ist da los, wie funktioniert dieses Feature eigentlich genau?
Um die Vorschau anzuzeigen, folgt der Messaging-Client dem Link, oft schon bevor der Sender Send drückt, und holt von der dortigen Website einige nach dem Open-Graph-Protokoll [1] definierte Daten ein. Diesen Meta-Standard für Webseiten hat ursprünglich die Firma Facebook auf den Weg gebracht. Er diente dazu, das Internet zu archivieren und Webseiten anhand indizierter Metainformationen zu katalogisieren.
Drei Meta-Tags aus diesem Standard greifen nun typische Messaging-Clients im HTML-Code der gelinkten Webseite auf. Sie verwenden diese Informationen wiederum dazu, eine Vorschau anzuzeigen. Dabei entspricht »”og:title”« dem Kurztitel der Webseite, »”og:description”« bietet eine einzeilige Zusammenfassung des Inhalts und »”og:image”« enthält eine URL auf ein JPEG, das die Vorschau bebildern soll.
Dabei müssen Webseitenbetreiber einiges beachten, damit Links auf ihren Inhalt kompatibel mit den Vorschauregeln bleiben. Da kein Standard genau ausformuliert, was der Server machen muss, hilft nur stetiges Testen mit allen gängigen Messaging-Clients. So darf keiner der beiden Text-Tags eine bestimmte Länge überschreiten, und auch das Meta-Bildchen im JPEG-Format ist größenbeschränkt. Außerdem muss die Webseite zügig antworten, sonst bockt der Messenger und zeigt nur den Link ohne Info an.
Bildverarbeitung
Eine der ungeschriebenen Regeln der »og:«-Tags für die Vorschau lautet, dass das mit »og:image« referenzierte Foto nicht größer als 1200 x 630 Pixel sein darf. Deshalb muss das CGI nach dem Hochladen aus den gewöhnlich größeren Handyfotos (bei meinem iPhone 12 Mini etwa 4032 x 3024 Pixel) ein kleines Vorschaubild generieren. Erschwerend kommt hinzu, dass Smartphones Fotos oft rotiert abspeichern und die Rotation im EXIF-Header der JPG-Datei vermerken. Sie bürden es also der darstellenden Applikation auf, das Bild bei der Darstellung zu rotieren [2].
Die Funktion »scaleJPG()« in Listing 7 holt sich darum das Paket imaging von Github: Es bietet die praktische Funktion »Thumbnail()« für JPEG-Fotos an und liest mit dem Setting »AutoOrientation(true)« (Zeile 14) das Foto gleich noch rotiert ein, falls der EXIF-Header das angibt. So stehen die Daten des Thumbnails für den Kontaktabzug korrekt in der Datei und der Messaging-Client stellt das Bild richtig herum dar.
Im Dateinamen des verkleinerten Fotos wählt die Funktion »thumbName()« ab Zeile 7 die Extension »_s«, und die Vorschau mit »og:image« referenziert das Bild im Template in Listing 3 mit diesem neuen Namen.
Listing 7
image.go
package main
import (
"path/filepath"
"strings"
"github.com/disintegration/imaging"
)
func thumbName(fileName string) string {
ext := filepath.Ext(fileName)
name := strings.TrimSuffix(fileName, ext)
return name + "_s" + ext
}
func scaleJPG(inputPath string) error {
outputPath := thumbName(inputPath)
img, err := imaging.Open(inputPath, imaging.AutoOrientation(true))
if err != nil {
return err
}
width := img.Bounds().Dx() / 4
height := img.Bounds().Dy() / 4
thumbnail := imaging.Thumbnail(img, width, height, imaging.Lanczos)
err = imaging.Save(thumbnail, outputPath)
return err
}
Schwer zu erraten
Unter welchem Verzeichnis ein Foto-Set auf dem Webserver erscheint, sollte nur der Empfänger des Links wissen, also muss der Server zufällige Namen erzeugen, die nur schwer zu erraten sind. Wie das URL-Feld des Browsers in Abbildung 4 zeigt, legt das CGI-Programm einen Kontaktabzug aller Fotos eines Sets unter einem Verzeichnis ab, das aus einem 64 Zeichen langen Hex-String besteht. Den zu erraten ist in etwa so schwer wie ein Bitcoin zu schürfen!
Die Funktion »uploadDir()« in Listing 8 nutzt dazu einen Zufallsgenerator sowie das Paket crypto/sha256, um einen SHA-256-Hash im Hex-Format zu erzeugen. Damit niemand auf die Idee kommt, direkt beim Webserver anzufragen und einfach alle Verzeichnisse unter »uploads« aufzulisten, pflanzt Zeile 26 eine ».htaccess«-Datei ins Top-Verzeichnis und weist den Webserver mit »Options -Indexes« an, auf Anfragen zum Top-Verzeichnis mit Permission Denied zu antworten. Die einzelnen Unterverzeichnisse im SHA-256-Format hingegen liefert der Server klaglos aus.
Listing 8
util.go
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path"
)
func uploadDir() (string, error) {
root := os.Getenv("DOCUMENT_ROOT")
if root == "" {
return "", fmt.Errorf("No docroot")
}
randomStr := make([]byte, 32)
if _, err := rand.Read(randomStr); err != nil {
return "", err
}
hash := sha256.Sum256(randomStr)
shaDir := hex.EncodeToString(hash[:])
dir := path.Join(root, "uploads")
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
err := os.WriteFile(path.Join(dir, ".htaccess"), []byte("Options -Indexes\n"), 0644)
if err != nil {
return "", err
}
}
path := path.Join(dir, shaDir)
err := os.MkdirAll(path, 0755)
if err != nil {
return "", err
}
return path, nil
}
Flugs zusammengebaut
Das fertige CGI-Binary erzeugt die Kommandosequenz in Listing 9 aus den Sourcen. Da der Hoster unter Umständen eine andere Plattform fährt als das Entwicklungssystem, stellt »GOOS=linux GOARCH=386« sicher, dass das Executable bei meinem Hoster auf Linux und einem Intel-Prozessor läuft. Dann noch das Binary ins CGI-Verzeichnis des Servers kopieren und sicherstellen, dass es sich ausführen lässt, schließlich mittels Rsync alle Templates ins Verzeichnis »tmpl/« direkt unterhalb kopieren, und es kann losgehen. Die Verzeichnisse »uploads/« und die SHA-1-Directories erzeugt das Programm zur Laufzeit selbstständig.
Listing 9
build.sh
$ go mod init photoup $ go mod tidy $ GOOS=linux GOARCH=386 go build photoup.go tmpl.go image.go util.go
Go-CGI: Pro und Kontra
Ein monolithisches CGI-Programm in Go hat Vor- und Nachteile. Dass der Server bei jedem Aufruf ein dickes Binary startet, zehrt klar an der Performance. Mehr als ein paar Hundert Aufrufe pro Tag sollte man dem Server nicht zumuten. Zudem stellt ein solches Binary mit recht viel Code einschließlich eingebundener Bibliotheken ein Sicherheitsrisiko dar, da es frei im Internet steht und jeder Schurke darauf herumorgeln darf. Enthielte eine Library einen sicherheitsrelevanten Bug, käme ein Update eines Servers nicht beim statisch kompilierten Binary an, es sei denn, es würde aktiv neu kompiliert.
Dass aber alles aus einem Guss ist und das Binary keine dynamischen Libraries (außer vielleicht der Libc) heranzieht, stellt sich allerdings dann als unschlagbarer Vorteil heraus, wenn der Serverbetreiber Aktualisierungen am Betriebssystem vornimmt. Dergleichen Wartungsarbeiten bringen Skripts oder dynamisch kompilierte Programme gern ins Stolpern, etwa wenn eine Bibliothek beim Upgrade von Ubuntu auf eine neuere Version plötzlich nicht mehr kompatibel ist. Ein statisches Binary läuft hingegen bis ans Ende der Zeit immer stur weiter.
Nicht Hinz und Kunz
Für den Produktionsbetrieb gilt es noch zu beachten, dass das Upload-Skript nicht frei im Internet herumstehen sollte. Sonst könnten ja Hinz und Kunz Fotos hochladen und allerlei Schabernack treiben. Entsprechend sollte das CGI-Programm entweder über eine ».htaccess«-Datei nur autorisierte Nutzer zulassen oder das Programm selbst auf einem gültigen User-Token bestehen. Entsprechenden Code einzupflanzen, fällt nicht schwer. (uba)
Infos
- Open-Graph-Protokoll zur Vorschau: https://stackoverflow.com/questions/19778620/provide-an-image-for-whatsapp-link-sharing
- Snapshot: Mike Schilli, “Dreh dich im Kreis”, LM 02/2022, S. 82, https://www.lm-online.de/45751









