// Package main is the command-line entrypoint for the distribusi command. package main import ( "bytes" "encoding/base64" "fmt" "image/png" "io/fs" "io/ioutil" "net/http" "os" "os/exec" "path" "path/filepath" "sort" "strings" logrusStack "github.com/Gurpartap/logrus-stack" "github.com/barasher/go-exiftool" "github.com/disintegration/imaging" "github.com/gabriel-vasile/mimetype" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) // exiftooInstalled tells us if the exiftool binary is installed or not. var exiftoolInstalled = true // generatedInDistribusi is an internal marker to help recognise when // distribusi-go has generated files. var generatedInDistribusi = "" // htmlBody is the template for the index.html files that are generated by distribusi-go. var htmlBody = ` %s %s %s/", fname, fname) div, err := mkDiv(c, mtype, href, fname, false) if err != nil { return err } html = append(html, div) } for _, file := range files { fname := filepath.Base(file) if skip := shouldSkip(c, file, ignore); skip { continue } mtype, err := getMtype(file) if err != nil { return err } unknown, href, err := getHref(c, file, mtype) if err != nil { return err } div, err := mkDiv(c, mtype, href, fname, unknown) if err != nil { return err } html = append(html, div) } if err := writeIndex(absPath, html, c.String("css")); err != nil { return err } allDirs = append(allDirs, strings.Join(dirs, " ")) } return nil }); err != nil { return err } logrus.Debugf("generated files in following paths: %s", strings.Join(allDirs, " ")) return nil } // removeIndex safely removes an index.html, making sure to check that // distribusi generated it. func removeIndex(fpath string) error { file, err := os.ReadFile(fpath) if err != nil { return err } if strings.Contains(string(file), generatedInDistribusi) { if err := os.Remove(fpath); err != nil { return err } return nil } return nil } // getMtype determines file mimetype which helps to arrange HTML tags // appropriate for the file type. func getMtype(fpath string) (string, error) { mtype, err := mimetype.DetectFile(fpath) if err != nil { return "", err } return mtype.String(), nil } // genThumb generates an in-memory image thumbnail and encodes it into a base64 // representation which is suitable for embedding in HTML pages. func genThumb(c *cli.Context, fpath, caption string) (string, error) { knownFailureExts := []string{".ico", ".svg", ".xcf"} if sliceContains(knownFailureExts, filepath.Ext(fpath)) { return "", nil } imgSrc, err := imaging.Open(fpath, imaging.AutoOrientation(true)) if err != nil { logrus.Debugf("failed to generate thumbnail for %s", fpath) return "", err } img := imaging.Thumbnail(imgSrc, 450, 450, imaging.Lanczos) buf := new(bytes.Buffer) png.Encode(buf, img) imgBase64Str := base64.StdEncoding.EncodeToString(buf.Bytes()) return imgBase64Str, nil } // getCaption retrieves an embedded image caption via exif-tool. If not // exiftool is installed, we gracefully bail out. The caller is responsible for // handling the alternative. func getCaption(c *cli.Context, fpath string) (string, error) { var caption string if !exiftoolInstalled { return "", nil } exif, err := exiftool.NewExiftool() if err != nil { return caption, fmt.Errorf("failed to initialise exiftool, saw %v", err) } defer exif.Close() for _, finfo := range exif.ExtractMetadata(fpath) { if finfo.Err != nil { continue } for k, v := range finfo.Fields { if k == "Comment" { caption = fmt.Sprintf("%v", v) } } } return caption, nil } // parseMtype parses a mimetype string to simplify programmatic type lookups. func parseMtype(mtype string) (string, string) { stripCharset := strings.Split(mtype, ";") splitTypes := strings.Split(stripCharset[0], "/") ftype, stype := splitTypes[0], splitTypes[1] return ftype, stype } // sliceContains checks if an element is present in a list. func sliceContains(items []string, target string) bool { for _, item := range items { if item == target { return true } } return false } // trimFinalNewline trims newlines from the end of bytes just read from files. func trimFinalNewline(contents []byte) string { return strings.TrimSuffix(string(contents), "\n") } // getHref figures out which href tag corresponds to which file by navigating // the mimetype. If a type of file is unknown, this is signalled via the bool // return value. func getHref(c *cli.Context, fpath string, mtype string) (bool, string, error) { var href string var caption string var unknown bool fname := filepath.Base(fpath) ftype, stype := parseMtype(mtype) if ftype == "text" { fcontents, err := os.ReadFile(fpath) if err != nil { return unknown, href, err } if stype == "html" { href = fmt.Sprintf("
%s
", fname, trimFinalNewline(fcontents)) } else { href = fmt.Sprintf("
%s
", trimFinalNewline(fcontents)) } } else if ftype == "image" { caption = "" exifCaption, err := getCaption(c, fpath) if err != nil { return unknown, href, nil } if exifCaption != "" { caption = exifCaption } if stype == "gif" { href = fmt.Sprintf("", fname) } else { thumb, err := genThumb(c, fpath, caption) if err != nil { unknown = true href = fmt.Sprintf("%s", stype, fname, fname) } else { href = fmt.Sprintf( "
%s
", fname, thumb, caption, ) } } } else if ftype == "application" { if stype == "pdf" { href = fmt.Sprintf("", fname, fname) } else { unknown = true href = fmt.Sprintf("%s", stype, fname, fname) } } else if ftype == "audio" { href = fmt.Sprintf("", fname, stype) } else if ftype == "video" { href = fmt.Sprintf("", fname, stype) } else { unknown = true href = fmt.Sprintf("%s", stype, fname, fname) } return unknown, href, nil } // mkDiv cosntructs a HTML div for inclusion in the generated index.html. func mkDiv(c *cli.Context, mtype string, href, fname string, unknown bool) (string, error) { var div string filename := fmt.Sprintf("%s", fname) ftype, _ := parseMtype(mtype) if ftype == "text" { div = fmt.Sprintf("
%s%s
", fname, ftype, href, filename) } else if ftype == "os" { div = fmt.Sprintf("
%s
", fname, ftype, href) } else { if unknown { // we really don't know what this is, so the filename is the href and we avoid adding it again div = fmt.Sprintf("
%s
", fname, ftype, href) } else { // images, videos, etc. still get a filename div = fmt.Sprintf("
%s%s
", fname, ftype, href, filename) } } return div, nil } // writeIndex writes a new index.html. func writeIndex(fpath string, html []string, styles string) error { body := fmt.Sprintf(htmlBody, generatedInDistribusi, "", strings.Join(html, "\n")) if styles != "" { absPath, err := filepath.Abs(styles) if err != nil { return err } if _, err := os.Stat(absPath); !os.IsNotExist(err) { contents, err := os.ReadFile(absPath) if err != nil { return err } body = fmt.Sprintf(htmlBody, generatedInDistribusi, contents, strings.Join(html, "\n")) } } HTMLPath := path.Join(fpath, "index.html") contents := []byte(body) if _, err := os.Stat(HTMLPath); err != nil { if os.IsNotExist(err) { if err := ioutil.WriteFile(HTMLPath, contents, 0644); err != nil { return err } } else { return err } } else { file, err := os.ReadFile(HTMLPath) if err != nil { return err } if strings.Contains(string(file), generatedInDistribusi) { if err := ioutil.WriteFile(HTMLPath, contents, 0644); err != nil { return err } } } return nil } // serveHTTP serves a web server for browsing distribusi. func serveHTTP(fpath string) error { fs := http.FileServer(http.Dir(fpath)) http.Handle("/", fs) logrus.Info("distribusi live @ http://localhost:3000") if err := http.ListenAndServe(":3000", nil); err != nil { return err } return nil } // wipeGeneratedFiles removes all distribusi generated files. func wipeGeneratedFiles(dir string) error { if err := filepath.WalkDir(dir, func(fpath string, dirEntry fs.DirEntry, err error) error { fname := filepath.Base(fpath) if fname == "index.html" { if err := removeIndex(fpath); err != nil { return err } } return nil }); err != nil { return err } return nil } // shouldSkip checks if paths should be skipped over. func shouldSkip(c *cli.Context, fpath string, ignore []string) bool { if sliceContains(ignore, filepath.Base(fpath)) { return true } fpaths := strings.Split(fpath, "/") for _, part := range fpaths { if strings.HasPrefix(part, ".") { if !c.Bool("hidden") { return true } } } return false }