You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
669 lines
16 KiB
669 lines
16 KiB
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// 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 = "<meta name='generator' content='distribusi-go' />"
|
|
|
|
// htmlBody is the template for the index.html files that are generated by distribusi-go.
|
|
var htmlBody = `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<!-- Generated with distribusi-go https://git.vvvvvvaria.org/decentral1se/distribusi-go -->
|
|
%s
|
|
|
|
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
|
|
|
<style>
|
|
.image {
|
|
max-width: 100%%;
|
|
}
|
|
|
|
.pdf {
|
|
width: 640px;
|
|
height: 640px;
|
|
}
|
|
|
|
.os::before {
|
|
content: "📁 ";
|
|
font-size: 18px;
|
|
}
|
|
|
|
.filename {
|
|
display: block;
|
|
font-family: mono;
|
|
}
|
|
|
|
.gif {
|
|
width: 450px;
|
|
max-height: 450px;
|
|
}
|
|
|
|
div {
|
|
display: inline-block;
|
|
vertical-align: top;
|
|
margin: 1em;
|
|
padding: 1em;
|
|
}
|
|
|
|
video {
|
|
width: 450px;
|
|
max-height: 450px;
|
|
}
|
|
|
|
%s
|
|
</style>
|
|
</head>
|
|
<body>
|
|
%s
|
|
</body>
|
|
</html
|
|
`
|
|
|
|
// main is the command-line entrypoint.
|
|
func main() {
|
|
app := &cli.App{
|
|
Name: "distribusi-go",
|
|
Version: "0.1.0-alpha",
|
|
Usage: "low-tech content management system for the web",
|
|
Description: `
|
|
Distribusi is a content management system for the web that produces static
|
|
index pages based on folders in the files system. It is inspired by the
|
|
automatic index functions featured in several popular web servers. Distribusi
|
|
works by traversing the file system and directory hierarchy to automatically
|
|
list all the files in the directory, detect the file types and providing them
|
|
with relevant html classes and tags for easy styling.
|
|
|
|
Example:
|
|
|
|
distribusi -p <path> -s
|
|
`,
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "css",
|
|
Aliases: []string{"c"},
|
|
Usage: "css file for custom styles",
|
|
Value: "",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "debug",
|
|
Aliases: []string{"d"},
|
|
Usage: "debug logging",
|
|
Value: false,
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "hidden",
|
|
Aliases: []string{"n"},
|
|
Usage: "include hidden directories (e.g. .git)",
|
|
Value: false,
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "ignore",
|
|
Aliases: []string{"i"},
|
|
Usage: "ignore specific paths, (e.g. \"*.gif, *.txt, mydir\" )",
|
|
Value: "",
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "path",
|
|
Aliases: []string{"p"},
|
|
Usage: "path to distribusify",
|
|
Required: true,
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "serve",
|
|
Aliases: []string{"s"},
|
|
Usage: "serve distribusi for the web",
|
|
Value: false,
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "wipe",
|
|
Aliases: []string{"w"},
|
|
Usage: "remove all generated files",
|
|
Value: false,
|
|
},
|
|
},
|
|
Authors: []*cli.Author{
|
|
{
|
|
Name: "Varia",
|
|
Email: "info@varia.zone",
|
|
},
|
|
},
|
|
Before: func(c *cli.Context) error {
|
|
if c.Bool("debug") {
|
|
logrus.SetLevel(logrus.DebugLevel)
|
|
logrus.SetFormatter(&logrus.TextFormatter{})
|
|
logrus.SetOutput(os.Stderr)
|
|
logrus.AddHook(logrusStack.StandardHook())
|
|
}
|
|
|
|
return nil
|
|
},
|
|
Action: func(c *cli.Context) error {
|
|
root, err := filepath.Abs(c.String("path"))
|
|
if err != nil {
|
|
logrus.Fatal(err)
|
|
}
|
|
|
|
var ignore []string
|
|
if c.String("ignore") != "" {
|
|
ignore = strings.Split(c.String("ignore"), ",")
|
|
|
|
for i := range ignore {
|
|
ignore[i] = strings.TrimSpace(ignore[i])
|
|
}
|
|
|
|
logrus.Debugf("parsed %s as ignore patterns", strings.Join(ignore, " "))
|
|
}
|
|
|
|
if c.Bool("wipe") {
|
|
if err := wipeGeneratedFiles(root); err != nil {
|
|
logrus.Fatal(err)
|
|
}
|
|
|
|
logrus.Infof("wiped all generated files in %s", root)
|
|
|
|
return nil
|
|
}
|
|
|
|
_, err = exec.LookPath("exiftool")
|
|
if err != nil {
|
|
logrus.Debug("exiftool is not installed, skipping image captions")
|
|
exiftoolInstalled = false
|
|
}
|
|
|
|
logrus.Debugf("selecting %s as distribusi root", root)
|
|
|
|
if err := distribusify(c, root, ignore); err != nil {
|
|
logrus.Fatal(err)
|
|
}
|
|
|
|
if c.Bool("serve") {
|
|
if err := serveHTTP(root); err != nil {
|
|
logrus.Fatal(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
sort.Sort(cli.FlagsByName(app.Flags))
|
|
sort.Sort(cli.CommandsByName(app.Commands))
|
|
|
|
if err := app.Run(os.Args); err != nil {
|
|
logrus.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// distribusify runs the main distribusi generation logic.
|
|
func distribusify(c *cli.Context, root string, ignore []string) error {
|
|
var allDirs []string
|
|
|
|
if err := filepath.Walk(root, func(fpath string, finfo os.FileInfo, err error) error {
|
|
|
|
skip, err := shouldSkip(c, fpath, ignore)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if skip {
|
|
return nil
|
|
}
|
|
|
|
var html []string
|
|
|
|
if finfo.IsDir() {
|
|
var dirs []string
|
|
var files []string
|
|
|
|
absPath, err := filepath.Abs(fpath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
contents, err := ioutil.ReadDir(absPath)
|
|
if err != nil {
|
|
logrus.Debugf("unable to read %s", absPath)
|
|
return nil
|
|
}
|
|
|
|
for _, content := range contents {
|
|
if content.IsDir() {
|
|
dirs = append(dirs, path.Join(absPath, content.Name()))
|
|
} else {
|
|
if content.Name() == "index.html" {
|
|
file, err := os.ReadFile(path.Join(absPath, content.Name()))
|
|
if err != nil {
|
|
logrus.Debugf("unable to read %s, skipping", content.Name())
|
|
continue
|
|
}
|
|
if strings.Contains(string(file), generatedInDistribusi) {
|
|
continue
|
|
}
|
|
}
|
|
files = append(files, path.Join(absPath, content.Name()))
|
|
}
|
|
}
|
|
|
|
if len(dirs) == 0 && len(files) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if root != fpath {
|
|
href := "<a href='../'>../</a>"
|
|
div, err := mkDiv(c, "os/directory", href, "menu", false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
html = append(html, div)
|
|
}
|
|
|
|
for _, dir := range dirs {
|
|
fname := filepath.Base(dir)
|
|
|
|
skip, err := shouldSkip(c, fname, ignore)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if skip {
|
|
continue
|
|
}
|
|
|
|
mtype := "os/directory"
|
|
|
|
href := fmt.Sprintf("<a href='%s'>%s/</a>", 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)
|
|
|
|
skip, err := shouldSkip(c, fname, ignore)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if skip {
|
|
continue
|
|
}
|
|
|
|
mtype, err := getMtype(file)
|
|
if err != nil {
|
|
logrus.Debugf("failed to read mimetype of %s", file)
|
|
continue
|
|
}
|
|
|
|
unknown, href, err := getHref(c, file, mtype)
|
|
if err != nil {
|
|
logrus.Debugf("failed to generate href for %s", file)
|
|
continue
|
|
}
|
|
|
|
div, err := mkDiv(c, mtype, href, fname, unknown)
|
|
if err != nil {
|
|
logrus.Debugf("failed to generate div for %s", file)
|
|
continue
|
|
}
|
|
|
|
html = append(html, div)
|
|
}
|
|
|
|
if err := writeIndex(absPath, html, c.String("css")); err != nil {
|
|
logrus.Debugf("unable to generated %s, skipping", path.Join(absPath, "index.html"))
|
|
return nil
|
|
}
|
|
|
|
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 "", fmt.Errorf("unable to generate thumbnail for %s", fpath)
|
|
}
|
|
|
|
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("<section id=\"%s\">%s</section>", fname, trimFinalNewline(fcontents))
|
|
} else {
|
|
href = fmt.Sprintf("<pre>%s</pre>", 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("<img class='gif' src=\"%s\" />", fname)
|
|
} else {
|
|
thumb, err := genThumb(c, fpath, caption)
|
|
if err != nil {
|
|
unknown = true
|
|
href = fmt.Sprintf("<a class='%s' href=\"%s\">%s</a>", stype, fname, fname)
|
|
} else {
|
|
href = fmt.Sprintf(
|
|
"<figure><a href=\"%s\"><img class='thumbnail' loading='lazy' src='data:image/jpg;base64,%s'></a><figcaption>%s</figcaption></figure>",
|
|
fname, thumb, caption,
|
|
)
|
|
}
|
|
}
|
|
} else if ftype == "application" {
|
|
if stype == "pdf" {
|
|
href = fmt.Sprintf("<object data=\"%s\" class='pdf' type='application/pdf'><embed src=\"%s\" type='application/pdf'/></object>", fname, fname)
|
|
} else {
|
|
unknown = true
|
|
href = fmt.Sprintf("<a class='%s' href=\"%s\">%s</a>", stype, fname, fname)
|
|
}
|
|
} else if ftype == "audio" {
|
|
href = fmt.Sprintf("<audio controls> <source src=\"%s\" type='audio/%s'> Your browser does not support the audio element. </audio>", fname, stype)
|
|
} else if ftype == "video" {
|
|
href = fmt.Sprintf("<video controls> <source src=\"%s\" type='video/%s'> </video>", fname, stype)
|
|
} else {
|
|
unknown = true
|
|
href = fmt.Sprintf("<a class='%s' href=\"%s\">%s</a>", 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("<span class='filename'>%s</span>", fname)
|
|
|
|
ftype, _ := parseMtype(mtype)
|
|
|
|
if ftype == "text" {
|
|
div = fmt.Sprintf("<div id=\"%s\" class='%s'>%s%s</div>", fname, ftype, href, filename)
|
|
} else if ftype == "os" {
|
|
div = fmt.Sprintf("<div id=\"%s\" class='%s'>%s</div>", 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("<div id=\"%s\" class='%s'>%s</div>", fname, ftype, href)
|
|
} else {
|
|
// images, videos, etc. still get a filename
|
|
div = fmt.Sprintf("<div id=\"%s\" class='%s'>%s%s</div>", 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 nil
|
|
}
|
|
|
|
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 {
|
|
logrus.Debugf("unable to write %s, skipping", HTMLPath)
|
|
return nil
|
|
}
|
|
} else {
|
|
logrus.Debugf("unable to read %s, skipping", HTMLPath)
|
|
return nil
|
|
}
|
|
} else {
|
|
file, err := os.ReadFile(HTMLPath)
|
|
if err != nil {
|
|
logrus.Debugf("unable to read %s, skipping", HTMLPath)
|
|
return nil
|
|
}
|
|
|
|
if strings.Contains(string(file), generatedInDistribusi) {
|
|
if err := ioutil.WriteFile(HTMLPath, contents, 0644); err != nil {
|
|
logrus.Debugf("unable to write %s, skipping", HTMLPath)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
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, error) {
|
|
for _, pattern := range ignore {
|
|
match, err := filepath.Match(pattern, filepath.Base(fpath))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if match {
|
|
logrus.Debugf("skipping %s, matched %s", filepath.Base(fpath), pattern)
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
fpaths := strings.Split(fpath, "/")
|
|
for _, part := range fpaths {
|
|
if strings.HasPrefix(part, ".") {
|
|
if !c.Bool("hidden") {
|
|
logrus.Debugf("skipping %s, hidden directory", fpath)
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|