diff --git a/.gitignore b/.gitignore index 7e016e8..749bc60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .DS_Store tmp indir +indir-* +.zed +index.html diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7858ed9 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +GOOS := $(shell uname -s | tr '[:upper:]' '[:lower:]') +GOARCH := $(shell uname -m) +TRIPLE := $(GOOS)-$(GOARCH) + +.PHONY: build + +build: + go build -o indir-$(TRIPLE) . + ln -sf ./indir-$(TRIPLE) ./indir + +build-multiplatform: indir-linux-amd64 indir-darwin-arm64 + +indir-linux-amd64: + GOOS=linux GOARCH=amd64 go build -o ./indir-linux-amd64 . + +indir-darwin-arm64: + GOOS=darwin GOARCH=arm64 go build -o ./indir-darwin-arm64 . + +clean: + rm -rf \ + ./indir \ + ./indir-$(TRIPLE) \ + ./indir-darwin-arm64 \ + ./indir-linux-amd64 + +install: build + cp ./indir-$(TRIPLE) /usr/local/bin/indir + chmod +x /usr/local/bin/indir diff --git a/README.md b/README.md index 2f25adc..de37ec7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ a nifty static file indexer -indir provides a nice, simple frontend for browsing your web directories! +indir provides a nice, simple web frontend for browsing your file directories! heavily inspired by traditional file indexers provided by the likes of nginx and apache, with some modern amenities on top :) @@ -16,7 +16,11 @@ indir [--host address] [--port port] [--root http_root] directory --root http_root hosts on the specified subdirectory, i.e. `/files/` ``` -## to-do: -- [x] use templates instead of hard-coded HTML (i was lazy) -- [ ] directory header from readme file -- [ ] directory stylesheet overrides +## features + +- **index files:** if available, indir will prefer serving an `index.html` file +instead of a directory listing. +- **custom CSS:** indir will append the contents of a local `index.css` file to +the default stylesheet, if applicable. +- **readme files:** adding a `readme.md` file (case insensitive) will append +its contents beneath the directory tree in rendered markdown. diff --git a/go.mod b/go.mod index c405b03..0b80eac 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ -module git.arimelody.me/ari/indir +module forge.arimelody.space/ari/indir go 1.24.3 + +require github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..52d2bfd --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= +github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= diff --git a/log/main.go b/log/main.go new file mode 100644 index 0000000..a58ee28 --- /dev/null +++ b/log/main.go @@ -0,0 +1,57 @@ +package log + +import ( + "fmt" + "net/http" + "strconv" + "time" +) + +type LoggingResponseWriter struct { + http.ResponseWriter + Status int +} + +var COL_Reset = "\033[0m" +var COL_Red = "\033[31m" +var COL_Green = "\033[32m" +var COL_Yellow = "\033[33m" +var COL_Blue = "\033[34m" +var COL_Purple = "\033[35m" +var COL_Cyan = "\033[36m" +var COL_Gray = "\033[37m" +var COL_White = "\033[97m" + +func HTTPLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + lrw := LoggingResponseWriter{w, http.StatusOK} + + next.ServeHTTP(&lrw, r) + + after := time.Now() + difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000 + elapsed := "<1" + if difference >= 1 { + elapsed = strconv.Itoa(difference) + } + + statusColour := COL_Reset + + if lrw.Status - 600 <= 0 { statusColour = COL_Red } + if lrw.Status - 500 <= 0 { statusColour = COL_Yellow } + if lrw.Status - 400 <= 0 { statusColour = COL_White } + if lrw.Status - 300 <= 0 { statusColour = COL_Green } + + fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", + after.Format(time.UnixDate), + r.Method, + r.URL.Path, + statusColour, + lrw.Status, + COL_Reset, + elapsed, + r.Header["User-Agent"][0]) + }) +} diff --git a/main.go b/main.go index a6b0606..4a09b47 100755 --- a/main.go +++ b/main.go @@ -2,38 +2,15 @@ package main import ( "fmt" - "html/template" - "io/fs" - "mime" "net/http" "os" - "path" - "slices" "strconv" "strings" - "time" - _ "embed" + + "forge.arimelody.space/ari/indir/log" + "forge.arimelody.space/ari/indir/web" ) -type ( - Directory struct { - Name string - Root bool - Files []*File - } - - File struct { - Name string - URI string - IsDir bool - Size string - ModifiedDate string - } -) - -//go:embed templates/dir.html -var dirHTML string - func main() { if len(os.Args) < 2 { printHelp() } @@ -101,191 +78,30 @@ func main() { ".DS_Store", } - dirTemplate, err := template.New("dir").Parse(dirHTML) - if err != nil { - fmt.Fprintf(os.Stderr, "fatal: failed to parse directory template: %v\n", err) - os.Exit(1) - } - fmt.Printf("Now hosting \"%s\" at http://%s:%d", filesDir, host, port) if root != "/" { fmt.Printf("%s", root) } fmt.Println(".") - http.ListenAndServe(fmt.Sprintf("%s:%d", host, port), HTTPLog(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.URL.Path, root) { - http.NotFound(w, r) - return - } - isRoot := r.URL.Path == root + app := web.AppState{ + Root: root, + FilesDir: filesDir, + IgnoredFiles: ignoredFiles, + } - filepath := path.Join(filesDir, strings.TrimPrefix(r.URL.Path, root)) - info, err := os.Stat(filepath) - if err != nil { - http.NotFound(w, r) - return - } - - // downloading file - if !info.IsDir() { - if strings.HasSuffix(r.URL.Path, "/") { - http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, "/"), http.StatusFound) - return - } - - file, err := os.Open(filepath) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - defer func() { - err := file.Close() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to close file %s: %v\n", filepath, err) - } - }() - - mimeType := "application/octet-stream" - extPos := strings.LastIndex(info.Name(), ".") - if extPos != -1 { - mimeType = mime.TypeByExtension(info.Name()[:extPos]) - } - - w.Header().Set("Content-Type", mimeType) - w.WriteHeader(http.StatusOK) - - _, err = file.WriteTo(w) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to send file %s: %v\n", filepath, err) - } - return - } - - if !strings.HasSuffix(r.URL.Path, "/") { - http.Redirect(w, r, r.URL.Path + "/", http.StatusFound) - return - } - - data := Directory{ - Root: isRoot, - Name: r.URL.Path, - Files: []*File{}, - } - - fsDir := os.DirFS(filepath) - directories, err := fs.ReadDir(fsDir, ".") - for _, dir := range directories { - name := dir.Name() - if slices.Contains(ignoredFiles, name) { continue } - - info, err := dir.Info() - if err != nil { continue } - - var uri string - if isRoot { - uri = root + name - } else { - uri = r.URL.Path + name - } - - sizeStr := "—" - if !info.IsDir() { - size := info.Size() - sizeDenom := "B" - if size > 1000 { - size /= 1000 - sizeDenom = "KB" - } - if size > 1000 { - size /= 1000 - sizeDenom = "MB" - } - if size > 1000 { - size /= 1000 - sizeDenom = "GB" - } - sizeStr = fmt.Sprintf("%d%s", size, sizeDenom) - } - - dateStr := info.ModTime().Format("02-Jan-2006 15:04") - - data.Files = append(data.Files, &File{ - Name: name, - URI: uri, - IsDir: info.IsDir(), - Size: sizeStr, - ModifiedDate: dateStr, - }) - } - if err != nil { - fmt.Fprintf(os.Stderr, "failed to open directory: %v\n", err) - } - - w.Header().Set("Content-Type", "text/html") - w.Header().Set("Server", "indir") - w.WriteHeader(http.StatusOK) - dirTemplate.Execute(w, data) - }))) -} - -type LoggingResponseWriter struct { - http.ResponseWriter - Status int -} - -var COL_Reset = "\033[0m" -var COL_Red = "\033[31m" -var COL_Green = "\033[32m" -var COL_Yellow = "\033[33m" -var COL_Blue = "\033[34m" -var COL_Purple = "\033[35m" -var COL_Cyan = "\033[36m" -var COL_Gray = "\033[37m" -var COL_White = "\033[97m" - -func HTTPLog(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - lrw := LoggingResponseWriter{w, http.StatusOK} - - next.ServeHTTP(&lrw, r) - - after := time.Now() - difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000 - elapsed := "<1" - if difference >= 1 { - elapsed = strconv.Itoa(difference) - } - - statusColour := COL_Reset - - if lrw.Status - 600 <= 0 { statusColour = COL_Red } - if lrw.Status - 500 <= 0 { statusColour = COL_Yellow } - if lrw.Status - 400 <= 0 { statusColour = COL_White } - if lrw.Status - 300 <= 0 { statusColour = COL_Green } - - fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n", - after.Format(time.UnixDate), - r.Method, - r.URL.Path, - statusColour, - lrw.Status, - COL_Reset, - elapsed, - r.Header["User-Agent"][0]) - }) + http.ListenAndServe( + fmt.Sprintf("%s:%d", host, port), + log.HTTPLog(web.Handler(&app)), + ) } func printHelp() { fmt.Printf( - `%s [--host address] [--port port] [--root http_root] directory + `indir [--host address] [--port port] [--root http_root] directory --help shows this help message --host address hosts on the specified address --port port hosts on the specified port --root http_root hosts on the specified subdirectory, i.e. `+"`/files/`\n", - os.Args[0], ) os.Exit(0) } diff --git a/templates/dir.html b/templates/dir.html deleted file mode 100644 index 7c26cfb..0000000 --- a/templates/dir.html +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - Files in {{.Name}} - - - -
-

Files in {{.Name}}

-
- - - - - - - {{if not .Root}} - - - - - - {{end}} - {{range .Files}} - - - - - - {{end}} -
NameSizeModified
../
{{.Name}}{{if .IsDir}}—{{else}}{{.Size}}{{end}}{{.ModifiedDate}}
-
-
- - - diff --git a/web/dir.html b/web/dir.html new file mode 100644 index 0000000..d5f4d74 --- /dev/null +++ b/web/dir.html @@ -0,0 +1,191 @@ + + + + + + Files in {{.Name}} + + {{if .CSS}} + + {{end}} + + +
+

Files in {{.Name}}

+
+ + + + + + + {{if not .Root}} + + + + + + {{end}} {{range .Files}} + + + + + + {{end}} +
NameSizeModified
../
{{.Name}}{{if .IsDir}}—{{else}}{{.Size}}{{end}}{{.ModifiedDate}}
+
+ {{if .Readme}} +
+ {{.Readme}} +
+ {{end}} +
+ + + diff --git a/web/main.go b/web/main.go new file mode 100644 index 0000000..5f0c862 --- /dev/null +++ b/web/main.go @@ -0,0 +1,218 @@ +package web + +import ( + _ "embed" + "fmt" + "html/template" + "io/fs" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" +) + +type ( + AppState struct { + Root string + FilesDir string + IgnoredFiles []string + } + + Directory struct { + Name string + Root bool + Files []*File + Readme template.HTML + CSS template.CSS + } + + File struct { + Name string + URI string + IsDir bool + Size string + ModifiedDate string + } +) + +var mimeTypes = map[string]string { + ".go": "text/plain", + ".rs": "text/plain", + ".c": "text/plain", + ".h": "text/plain", + ".cpp": "text/plain", + ".hpp": "text/plain", + ".java": "text/plain", +} + +//go:embed dir.html +var dirTemplateSrc string +var dirTemplate = template.Must(template.New("dir").Parse(dirTemplateSrc)) + +func Handler(app *AppState) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, app.Root) { + http.NotFound(w, r) + return + } + isRoot := r.URL.Path == app.Root + + fpath := path.Join(app.FilesDir, strings.TrimPrefix(r.URL.Path, app.Root)) + info, err := os.Stat(fpath) + if err != nil { + http.NotFound(w, r) + return + } + + // downloading file + if !info.IsDir() { + if strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, "/"), http.StatusFound) + return + } + + file, err := os.Open(fpath) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + defer func() { + err := file.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to close file %s: %v\n", fpath, err) + } + }() + + mimeType := "application/octet-stream" + extPos := strings.LastIndex(info.Name(), ".") + ext := string(info.Name()[strings.LastIndex(info.Name(), "."):]) + fmt.Println(ext) + if extPos != -1 { + if m := mime.TypeByExtension(ext); m != "" { + mimeType = m + } else if m, ok := mimeTypes[ext]; ok { + mimeType = m + } + } + + w.Header().Set("Content-Type", mimeType) + w.WriteHeader(http.StatusOK) + + _, err = file.WriteTo(w) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to send file %s: %v\n", fpath, err) + } + return + } + + if !strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, r.URL.Path + "/", http.StatusFound) + return + } + + // serve index.html if present (case-sensitive) + indexPath := filepath.Join(fpath, "index.html") + if indexInfo, err := os.Stat(indexPath); err == nil && !indexInfo.IsDir() { + http.ServeFile(w, r, indexPath) + return + } + + // load index.css if present + var customCSS template.CSS + cssPath := filepath.Join(fpath, "index.css") + if cssInfo, err := os.Stat(cssPath); err == nil && !cssInfo.IsDir() { + if src, err := os.ReadFile(cssPath); err == nil { + customCSS = template.CSS(src) + } + } + + // embeded readme + var readmeHTML template.HTML + entries, err := os.ReadDir(fpath) + if err == nil { + for _, entry := range entries { + if strings.EqualFold(entry.Name(), "readme.md") { + src, err := os.ReadFile(filepath.Join(fpath, entry.Name())) + if err == nil { + mdFlags := html.CommonFlags | html.HrefTargetBlank + mdRenderer := html.NewRenderer(html.RendererOptions{Flags: mdFlags}) + mdParser := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) + md := mdParser.Parse(src) + readmeHTML = template.HTML(markdown.Render(md, mdRenderer)) + } + break + } + } + } + + data := Directory{ + Root: isRoot, + Name: r.URL.Path, + Files: []*File{}, + Readme: readmeHTML, + CSS: customCSS, + } + + fsDir := os.DirFS(fpath) + directories, err := fs.ReadDir(fsDir, ".") + for _, dir := range directories { + name := dir.Name() + if slices.Contains(app.IgnoredFiles, name) { continue } + + info, err := dir.Info() + if err != nil { continue } + + var uri string + if isRoot { + uri = app.Root + name + } else { + uri = r.URL.Path + name + } + + sizeStr := "—" + if !info.IsDir() { + size := info.Size() + sizeDenom := "B" + if size > 1000 { + size /= 1000 + sizeDenom = "KB" + } + if size > 1000 { + size /= 1000 + sizeDenom = "MB" + } + if size > 1000 { + size /= 1000 + sizeDenom = "GB" + } + sizeStr = fmt.Sprintf("%d%s", size, sizeDenom) + } + + dateStr := info.ModTime().Format("02-Jan-2006 15:04") + + data.Files = append(data.Files, &File{ + Name: name, + URI: uri, + IsDir: info.IsDir(), + Size: sizeStr, + ModifiedDate: dateStr, + }) + } + if err != nil { + fmt.Fprintf(os.Stderr, "failed to open directory: %v\n", err) + } + + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Server", "indir") + w.WriteHeader(http.StatusOK) + dirTemplate.Execute(w, data) + } +}