Es ist angerichtet (Linux-Magazin, November 2024)

Zeige ich frisch geschossene und gut belichtete Digitalfotos herum, heißt es gleich "kannst mir das auch gleich schicken?!" und als gute Haut, die ich bin, lasse ich mich nicht lange bitten. Handelt es sich aber gleich um mehrere Fotos, und mehrere Leute lechzen nach dem Set, ist es einfacher, die Kollektion aufs Web zu stellen und einen Link darauf zu teilen, auf dass die gierigen Empfänger sich ihre Kopien selbst herunterladen können.

Diese Ausgabe zeigt nun ein CGI-Skript "photoup", das es dem Admin erlaubt, eines oder mehrere Fotos auf einen Webserver hochzuladen. Dort landen sie unter einem schwer zu erratenden Verzeichnis mit einem Layout, das die Fotos als Set oder in der Einzelansicht zeigt, und zwar bildschön sowohl auf dem Desktop als auch auf dem Smartphone. Und das Ganze wie immer in Go, 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, und gleichzeitig mittels Template-Schleifen noch dynamisch Fotos einbindet. Das reinste Hexenwerk!

Teilen mit Freunden

Um zum Beispiel die während einer Flugreise aufgenommenen Fotos zu teilen, habe ich sie mit dem Browser des Smartphones hochgeladen (Abbildungen 1 und 2). Der neu generierte Link (Abbildung 3) führt anschließend zu einem Kontaktabzug (Abbildung 4) und ein Mausklick auf ein Bild bringt die JPG-Datei mit der vollen Auflösung hoch (Abbildungen 5 und 6), damit Freunde sie nach Herzenslust herunterladen können.

Abbildung 1: Auf dem Mobiltelefon zeigt sich der Uploader formschön und funktional.

Abbildung 2: Drei Photos fertig zum Hochladen im Uploader

Abbildung 3: nach dem Upload erscheint ein Link

Abbildung 4: Ein Kontaktabzug hilft beim Herunterladen im Browser

Abbildung 5: Ein Klick auf das Bild bringt die volle Auflösung ...

Abbildung 6: ... auf Wunsch hineingezoomt.

GET oder POST

Den Upload der Fotos erledigt das CGI-Programm in Listing 1. Beim ersten Aufruf zeigt der Browser, egal ob im Smartphone oder auf dem Desktop, das Formular in Abbildung 1 an. Klickt der User auf "Choose Files", fragt der aufpoppende Dialog nach Dateien, entweder aus der Fotosammlung des Smartphones oder einem voreingestellten Folder auf dem Desktop. Nach der Bestätigung der Auswahl einer oder mehrerer Dateien veranlasst ein Klick auf den Button "Upload" des Formulars den Browser wieder dazu, den gleichen URL anzufordern, was noch einmal das CGI-Programm auf dem Server startet.

Listing 1: photoup.go

    01 package main
    02 import (
    03   "io"
    04   "mime/multipart"
    05   "net/http"
    06   "net/http/cgi"
    07   "os"
    08   "path"
    09   "regexp"
    10 )
    11 func main() {
    12   cgi.Serve(http.HandlerFunc(uploadHandler))
    13 }
    14 func uploadHandler(w http.ResponseWriter, r *http.Request) {
    15   tmpl := NewTmpl()
    16   err := tmpl.Init()
    17   if err != nil {
    18     panic(err)
    19   }
    20   if r.Method == "GET" {
    21     tmpl.RenderPage(w, "upload.html")
    22   } else if r.Method == "POST" {
    23     link, err := processUpload(w, r)
    24     if err != nil {
    25       http.Error(w, err.Error(), http.StatusInternalServerError)
    26       return
    27     }
    28     tmpl.Link = link
    29     tmpl.RenderPage(w, "done.html")
    30   }
    31 }
    32 func processUpload(w http.ResponseWriter, r *http.Request) (string, error) {
    33   err := r.ParseMultipartForm(10 << 20) // 10 MB limit
    34   if err != nil {
    35     return "", err
    36   }
    37   files := r.MultipartForm.File["files"]
    38   dir, err := uploadDir()
    39   if err != nil {
    40     return "", err
    41   }
    42   link := regexp.MustCompile(`/[^/]+/[^/]+$`).FindString(dir)
    43   names := []string{}
    44   for _, fh := range files {
    45     name, err := processFile(fh, dir)
    46     if err != nil {
    47       return "", err
    48     }
    49     names = append(names, name)
    50   }
    51   idx, err := os.OpenFile(path.Join(dir, "index.html"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
    52   if err != nil {
    53     return "", err
    54   }
    55   defer idx.Close()
    56   tpl := NewTmpl()
    57   if err = tpl.Init(); err != nil {
    58     return "", err
    59   }
    60   for _, name := range names {
    61     tpl.AddPhoto(name)
    62   }
    63   tpl.RenderPage(idx, "photos.html")
    64   return link, nil
    65 }
    66 func processFile(fh *multipart.FileHeader, dir string) (string, error) {
    67   file, err := fh.Open()
    68   if err != nil {
    69     return "", err
    70   }
    71   defer file.Close()
    72   fileName := fh.Filename
    73   savePath := path.Join(dir, sanitizeFileName(fileName))
    74   outFile, err := os.Create(savePath)
    75   if err != nil {
    76     return "", err
    77   }
    78   defer outFile.Close()
    79   if _, err = io.Copy(outFile, file); err != nil {
    80     return "", err
    81   }
    82   if err = scaleJPG(savePath); err != nil {
    83     return "", err
    84   }
    85   return fileName, nil
    86 }
    87 func sanitizeFileName(fileName string) string {
    88   allowed := regexp.MustCompile(`[^a-zA-Z0-9_\-\.]+`)
    89   return allowed.ReplaceAllString(fileName, "")
    90 }

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 aber nun die Methode "POST", ruft Zeile 23 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 User die Fotos später von dort als statische Dateien des Webservers herunterladen.

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" herauszuzuzeln, 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 muss Zeile 42 mit "/[^/]+/[^/]+$" sich jeweils von einem Querstrich über die nächsten Nicht-Querstriche bis zum nächsten Querstrich hangeln.

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 zum Zeitpunkt 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 HTML-Gerüst mit Inhalt füllen, indem sie einfache Template-Variablen im Layout mit aktuellen Werten besetzen, und das Ergebnis als statisches HTML abspeichern, das der Server dann entsprechend schneller ausliefern kann.

Was tatsächlich besser ist, bleibt zu diskutieren, es ist letzendlich Geschmacksache, 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. Im ersten Fall bleibt die Programmlogik innerhalb des Layouts simpel. Template-Engines bieten bewusst nur eingeschränkte Textersetzung. Vielleicht noch ein For-Schleiferl, um Listen darzustellen, aber das war's 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 den 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.

Listing 2: tmpl.go

    01 package main
    02 import (
    03   "io"
    04   tmpl "text/template"
    05   "time"
    06 )
    07 type Photo struct {
    08   Path  string
    09   Thumb string
    10 }
    11 type TmplData struct {
    12   CGI        string
    13   TmplEngine *tmpl.Template
    14   Date       string
    15   OgDesc     string
    16   OgImage    string
    17   Photos     []Photo
    18   Link       string
    19 }
    20 func NewTmpl() *TmplData {
    21   return &TmplData{}
    22 }
    23 func (td *TmplData) Init() error {
    24   td.Date = time.Now().Format("2006-01-02")
    25   td.OgDesc = "Photos uploaded"
    26   td.CGI = "/cgi/photopub"
    27   te, err := tmpl.ParseGlob("tmpl/*.html")
    28   td.TmplEngine = te
    29   return err
    30 }
    31 func (td *TmplData) AddPhoto(name string) {
    32   td.Photos = append(td.Photos, Photo{Path: name, Thumb: thumbName(name)})
    33 }
    34 func (td *TmplData) RenderPage(w io.Writer, tmplName string) error {
    35   if len(td.Photos) != 0 {
    36     td.OgImage = td.Photos[0].Thumb
    37   }
    38   for _, tpl := range []string{"intro.html", tmplName, "outro.html"} {
    39     err := td.TmplEngine.ExecuteTemplate(w, tpl, td)
    40     if err != nil {
    41       return err
    42     }
    43   }
    44   return nil
    45 }

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 Endung .html ein. Die Snippets in den Listings 3 bis 7 zeigen die Templates, die Platzhalter im Format {{.variable}} enthalten. Diese wird der Template-Engine mit den aktuellen Werten gleichnamiger Felder einer dem Engine zur Laufzeit überreichten Struktur ersetzen.

Einzig das Snippet mit den Fotos in Listing 4 nutzt eine Funktion des template-Pakets, die über reine Variablen-Interpolation hinausgeht. Die Funktion range ab Zeile 2 iteriert über die Variable Photos, die einen Array-Slice mit Photo-Strukturen erhält. Die einzelnen Elemente bieten ihrerseits die Felder Path und Thumb (entsprechend der Struktur-Definition ab Zeile 7 in Listing 2), die der HTML-Anker entsprechend referenziert.

Listing 3: intro.html

    01 <!DOCTYPE html>
    02 <html lang="en">
    03 <head>
    04     <meta name="og:title" content="Photoup">
    05     <meta property="og:description" content="{{.OgDesc}}">
    06     <meta property="og:image" content="{{.OgImage}}">
    07     <meta charset="UTF-8">
    08     <meta name="viewport" content="width=device-width, initial-scale=1.0">
    09     <link rel="stylesheet" href="/style.css">
    10 </head>
    11 <body>
    12      <nav class="navbar">
    13 	 <a href="../index.html">Photoup</a> &nbsp; &nbsp; &nbsp; &nbsp;
    14 	 <a href="#foobar">{{.Date}}</a>
    15     </nav>

Listing 4: photos.html

    1     <div class="photo-gallery">
    2     {{range .Photos}}
    3     <div class='photo'> <a HREF={{.Path}}><img src={{.Thumb}}></img></a></div>\n
    4     {{end}}
    5     </div>

Listing 5: outro.html

    1 </body>

Listing 6: upload.html

    1 <BR>
    2 <form enctype="multipart/form-data" action="{{.CGI}}" method="post">
    3     <input type="file" name="files" multiple>
    4     <input type="submit" value="Upload">
    5 </form>

Listing 7: done.html

    1 <P>
    2 &nbsp; Upload complete. <A HREF="{{.Link}}">Share this link</A>

Soll die Engine ein Template vom Stapel lassen, nimmt die Methode Execute() des template-Paketes eine Struktur entgegen, die in ihren Feldern aktuelle Werte für die Template-Variablen enthält. Als weiterer Parameter dient ein Writer-Objekt, an das die Engine das Ergebnis schreibt. Zu beachten ist, dass die Methode nur den einfachen Dateinamen (zum Beispiel intro.html) möchte, 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.

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 iMessage oder Whatsapp, zeigt der Client manchmal eine Vorschau an (Abbildung 7). Bei Links zu Artikeln in Nachrichtenmagazinen ist das oftmals ein kleines Bildchen, kombiniert mit der Schlagzeile sowie einigen Zeilen des Textes. Da klickt der Gesprächsteilnehmer dann neugierig drauf, während ein bloßer Link oft übersehen oder in unserer kurzatmigen Ära aus Zeitgründen ignoriert wird. Doch bei manchen Webseiten kommt gar keine Vorschau, bei anderen funktioniert's manchmal und manchmal nicht, was vom Tempo der Serverantwort abzuhängen scheint. Was ist da los, wie funktioniert dieses Feature eigentlich genau?

Abbildung 7: Vorschau eines Links in einem Whatsapp-Chat

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 ([2]) definierte Daten ein. Dieser Meta-Standard für Webseiten wurde ursprünglich von der Firma Facebook auf den Weg gebracht und 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, und verwenden diese Informationen wiederum dazu, eine Vorschau anzuzeigen: "og:title", den Kurztitel der Webseite, "og:description", eine einzeilige Zusammenfassung des Inhalts und "og:image", einen URL auf ein Jpeg-Image, das die Vorschau bebildern soll.

Dabei müssen Webseitenbetreiber einiges beachten, damit Links auf ihren Inhalt auch kompatibel mit den Vorschau-Regeln sind. 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 beschrä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 ist, dass das mit og:image referenzierte Foto nicht größer als 1200x630 Pixel sein darf. Deshalb muss das CGI nach dem Upload aus den gewöhnlich größeren Handyfotos (bei meinem iPhone 12 mini etwa 4032x3024) ein kleines Thumbnail-Image generieren. Erschwerend kommt hinzu, dass Smartphones ihre Fotos oft rotiert abspeichern und die Rotation im EXIF-Header der JPG-Datei vermerken, es also der darstellenden Applikation aufbürden, das Bild bei der Darstellung zu rotieren ([3]).

Listing 8: image.go

    01 package main
    02 import (
    03   "path/filepath"
    04   "strings"
    05   "github.com/disintegration/imaging"
    06 )
    07 func thumbName(fileName string) string {
    08   ext := filepath.Ext(fileName)
    09   name := strings.TrimSuffix(fileName, ext)
    10   return name + "_s" + ext
    11 }
    12 func scaleJPG(inputPath string) error {
    13   outputPath := thumbName(inputPath)
    14   img, err := imaging.Open(inputPath, imaging.AutoOrientation(true))
    15   if err != nil {
    16     return err
    17   }
    18   width := img.Bounds().Dx() / 4
    19   height := img.Bounds().Dy() / 4
    20   thumbnail := imaging.Thumbnail(img, width, height, imaging.Lanczos)
    21   err = imaging.Save(thumbnail, outputPath)
    22   return err
    23 }

Die Funktion scaleJPG() in Listing 8 holt sich darum das Paket imaging von Github, das die praktische Funktion Thumbnail() für Jpeg-Fotos anbietet und mit dem Setting AutoOrientation(true) (Zeile 14) das Foto auch gleich noch rotiert einliest, falls der Exif-Header dies angibt. So stehen die Daten des Thumbnails für den Kontaktabzug gleich richtig 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 in Listing 8 die Extension _s, und die Vorschau mit og:image referenziert es im Template in Listing 3 mit diesen neuen Namen.

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.

Listing 9: util.go

    01 package main
    02 import (
    03   "crypto/rand"
    04   "crypto/sha256"
    05   "encoding/hex"
    06   "fmt"
    07   "os"
    08   "path"
    09 )
    10 func uploadDir() (string, error) {
    11   root := os.Getenv("DOCUMENT_ROOT")
    12   if root == "" {
    13     return "", fmt.Errorf("No docroot")
    14   }
    15   randomStr := make([]byte, 32)
    16   if _, err := rand.Read(randomStr); err != nil {
    17     return "", err
    18   }
    19   hash := sha256.Sum256(randomStr)
    20   shaDir := hex.EncodeToString(hash[:])
    21   dir := path.Join(root, "uploads")
    22   if _, err := os.Stat(dir); os.IsNotExist(err) {
    23     if err := os.MkdirAll(dir, 0755); err != nil {
    24       return "", err
    25     }
    26     err := os.WriteFile(path.Join(dir, ".htaccess"), []byte("Options -Indexes\n"), 0644)
    27     if err != nil {
    28       return "", err
    29     }
    30   }
    31   path := path.Join(dir, shaDir)
    32   err := os.MkdirAll(path, 0755)
    33   if err != nil {
    34     return "", err
    35   }
    36   return path, nil
    37 }

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 Hexstring besteht. Den zu erraten ist in etwa so schwer wie eine Bitcoin zu schürfen! Die Funktion uploadDir() in Listing 9 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-Verzeichis mit "Permission Denied" zu antworten. Die einzelnen Unterverzeichnisse im SHA-256-Format hingegen liefert der Server klaglos aus.

Flugs zusammengebaut

Das fertige CGI-Binary erzeugt die Kommandosequenz in Listing 10 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 kopiert, sichergestellt, dass es sich ausführen lässt, und mittels rsync alle Templates ins Verzeichnis tmpl direkt unterhalb kopiert, und es kann losgehen. Die Verzeichnisse uploads und die Sha1-Directories erzeugt das Programm zur Laufzeit selbständig.

Listing 10: build.sh

    1 $ go mod init photoup
    2 $ go mod tidy
    3 $ 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 nun 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. Dann ist ein solches Binary mit recht viel Code einschließlich eingebundener Libraries ein Sicherheitsrisiko, 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 wird aktiv neu kompiliert.

Dass aber alles aus einem Guss ist, und das Binary keine dynamischen Libraries (außer vielleicht libc) heranzieht, stellt sich allerdings genau dann als unschlagbarer Vorteil heraus, wenn der Server-Betreiber Updates am Betriebssystem vornimmt. Dergleichen Wartungsarbeiten bringt Skripts oder dynamisch kompilierte Programme gerne zu Fall, etwa wenn eine Library beim Upgrade von Ubuntu X auf Ubuntu X+1 plötzlich nicht mehr kompatibel ist. Ein statisches Binary wird hingegen bis ans Ende der Zeit immer stur weiterlaufen.

Nicht Hinz und Kunz

Für den Produktionsbetrieb ist 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 User zulassen oder das Programm selbst auf einem gültigen User-Token bestehen, entsprechenden Code einzupflanzen ist nicht schwer.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/10/snapshot/

[2]

Open Graph-Protokoll zur Vorschau auf gelinkte Webseiten: https://stackoverflow.com/questions/19778620/provide-an-image-for-whatsapp-link-sharing

[3]

"Dreh dich im Kreis", Michael Schilli, Linux-Magazin 02/2022, https://www.linux-magazin.de/ausgaben/2022/02/snapshot/

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc