diff --git a/README.md b/README.md index de37ec7..b1dc9bc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ a nifty static file indexer -indir provides a nice, simple web frontend for browsing your file directories! +indir provides a nice, simple frontend for browsing your web directories! heavily inspired by traditional file indexers provided by the likes of nginx and apache, with some modern amenities on top :) @@ -18,9 +18,23 @@ indir [--host address] [--port port] [--root http_root] directory ## 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. +### file serving +indir has the possibility of automatically serving an `index.html` instead of displaying the directory view! + +### customization + +#### readme +drop a `README.md` into any directory and indir will render +it below the file listing with full markdown support! + +#### custom css +using a 'index.css' file, you can customize the directory view! + +--- + +## to-do: +- [x] use templates instead of hard-coded HTML (i was lazy) +- [x] directory header from readme file +- [x] directory stylesheet overrides +- [x] index.html serving support +- [x] fix mime-types for browser file view diff --git a/log/main.go b/log/main.go deleted file mode 100644 index a58ee28..0000000 --- a/log/main.go +++ /dev/null @@ -1,57 +0,0 @@ -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 4a09b47..cc2247e 100755 --- a/main.go +++ b/main.go @@ -2,13 +2,43 @@ package main import ( "fmt" + "html/template" + "io/fs" + "mime" "net/http" "os" + "path" + "path/filepath" + "slices" "strconv" "strings" + "time" + _ "embed" - "forge.arimelody.space/ari/indir/log" - "forge.arimelody.space/ari/indir/web" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" +) + +//go:embed templates/dir.html +var dirTemplateSrc string + +type ( + 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 + } ) func main() { @@ -78,30 +108,230 @@ func main() { ".DS_Store", } + dirTemplate, err := template.New("dir").Parse(dirTemplateSrc) + 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(".") - app := web.AppState{ - Root: root, - FilesDir: filesDir, - IgnoredFiles: ignoredFiles, - } + 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 - http.ListenAndServe( - fmt.Sprintf("%s:%d", host, port), - log.HTTPLog(web.Handler(&app)), - ) + fpath := path.Join(filesDir, strings.TrimPrefix(r.URL.Path, 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(), ".") + if extPos != -1 { + if m := mime.TypeByExtension(info.Name()[extPos:]); m != "" { + 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(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]) + }) } func printHelp() { fmt.Printf( - `indir [--host address] [--port port] [--root http_root] directory + `%s [--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/web/dir.html b/templates/dir.html similarity index 97% rename from web/dir.html rename to templates/dir.html index d5f4d74..bd812e3 100644 --- a/web/dir.html +++ b/templates/dir.html @@ -179,9 +179,9 @@