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!
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. |
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.
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.
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.
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.
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.
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.
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> 14 <a href="#foobar">{{.Date}}</a> 15 </nav>
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>
1 </body>
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>
1 <P> 2 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.
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.
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]).
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.
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.
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.
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.
1 $ go mod init photoup 2 $ go mod tidy 3 $ GOOS=linux GOARCH=386 go build photoup.go tmpl.go image.go util.go
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.
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.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/10/snapshot/
Open Graph-Protokoll zur Vorschau auf gelinkte Webseiten: https://stackoverflow.com/questions/19778620/provide-an-image-for-whatsapp-link-sharing
"Dreh dich im Kreis", Michael Schilli, Linux-Magazin 02/2022, https://www.linux-magazin.de/ausgaben/2022/02/snapshot/
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc