diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..92144b2
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,8 @@
+module github.com/tschaub/host
+
+go 1.21.0
+
+require (
+ github.com/alecthomas/kong v0.8.0
+ github.com/rs/cors v1.9.0
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..5ee9dac
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,10 @@
+github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0=
+github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA=
+github.com/alecthomas/kong v0.8.0 h1:ryDCzutfIqJPnNn0omnrgHLbAggDQM2VWHikE1xqK7s=
+github.com/alecthomas/kong v0.8.0/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
+github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
+github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE=
+github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..bd61a88
--- /dev/null
+++ b/index.html
@@ -0,0 +1,117 @@
+
+
+
+
+
+ Index of {{.Dir}}
+
+
+
+
+
+
+ Index of
+ {{range $i, $e := .Parents}}{{if $i}}/{{end}}{{$e.Name}}{{end}}
+
+
+
+ {{range .Entries}}
+ -
+ {{.Name}}
+
+ {{end}}
+
+
+
+
diff --git a/license.md b/license.md
new file mode 100644
index 0000000..68567ae
--- /dev/null
+++ b/license.md
@@ -0,0 +1,23 @@
+# License for host
+
+The host module is distributed under the MIT license. Find the full source
+here: http://tschaub.mit-license.org/
+
+Copyright Tim Schaub.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the “Software”), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..03fee70
--- /dev/null
+++ b/main.go
@@ -0,0 +1,188 @@
+package main
+
+import (
+ _ "embed"
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/alecthomas/kong"
+ "github.com/rs/cors"
+)
+
+func main() {
+ ctx := kong.Parse(&ServeCmd{}, kong.UsageOnError())
+ err := ctx.Run()
+ ctx.FatalIfErrorf(err)
+}
+
+type ServeCmd struct {
+ Port int `help:"Listen on this port." default:"4000"`
+ Dir string `help:"Serve files from this directory." arg:"" type:"existingdir"`
+ Cors bool `help:"Include CORS support (on by default)." default:"true" negatable:""`
+ Dot bool `help:"Serve dot files (files prefixed with a '.')" default:"false"`
+}
+
+func (c *ServeCmd) Run() error {
+ server := &Server{
+ dir: c.Dir,
+ port: c.Port,
+ cors: c.Cors,
+ dot: c.Dot,
+ }
+
+ return server.Start()
+}
+
+type Server struct {
+ dir string
+ port int
+ cors bool
+ dot bool
+}
+
+func excludeDot(handler http.Handler) http.Handler {
+ return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ parts := strings.Split(request.URL.Path, "/")
+ for _, part := range parts {
+ if strings.HasPrefix(part, ".") {
+ http.NotFound(response, request)
+ return
+ }
+ }
+
+ handler.ServeHTTP(response, request)
+ })
+}
+
+type IndexData struct {
+ Dir string
+ Parents []*Entry
+ Entries []*Entry
+}
+
+type Entry struct {
+ Name string
+ Path string
+ Type string
+}
+
+const (
+ fileType = "file"
+ folderType = "folder"
+)
+
+//go:embed index.html
+var indexHtml string
+
+func withIndex(dir string, dot bool, handler http.Handler) http.Handler {
+ indexTemplate := template.Must(template.New("index").Parse(indexHtml))
+ base := filepath.Base(dir)
+ return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
+ urlPath := request.URL.Path
+ if !strings.HasSuffix(urlPath, "/") {
+ handler.ServeHTTP(response, request)
+ return
+ }
+
+ dirPath := filepath.Join(dir, urlPath)
+ list, dirErr := os.ReadDir(dirPath)
+ if dirErr != nil {
+ if errors.Is(dirErr, os.ErrNotExist) {
+ http.NotFound(response, request)
+ return
+ }
+ http.Error(response, dirErr.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ entries := []*Entry{}
+ for _, item := range list {
+ name := item.Name()
+ if !dot && strings.HasPrefix(name, ".") {
+ continue
+ }
+ entry := &Entry{
+ Name: name,
+ Path: path.Join(urlPath, name),
+ }
+ if item.IsDir() {
+ entry.Type = folderType
+ entry.Path = entry.Path + "/"
+ } else {
+ entry.Type = fileType
+ }
+ entries = append(entries, entry)
+ }
+ sort.Slice(entries, func(i int, j int) bool {
+ iEntry := entries[i]
+ jEntry := entries[j]
+ if iEntry.Type == folderType && jEntry.Type != folderType {
+ return true
+ }
+ if jEntry.Type == folderType && iEntry.Type != folderType {
+ return false
+ }
+ return iEntry.Name < jEntry.Name
+ })
+
+ if urlPath != "/" {
+ parentEntry := &Entry{
+ Name: "..",
+ Path: path.Join(urlPath, ".."),
+ Type: folderType,
+ }
+ entries = append([]*Entry{parentEntry}, entries...)
+ }
+
+ parentParts := strings.Split(urlPath, "/")
+ parentParts = parentParts[:len(parentParts)-1]
+ parentEntries := make([]*Entry, len(parentParts))
+ for i, part := range parentParts {
+ entry := &Entry{
+ Name: part,
+ Path: strings.Join(parentParts[:i+1], "/") + "/",
+ Type: folderType,
+ }
+ if part == "" {
+ entry.Name = base
+ }
+ parentEntries[i] = entry
+ }
+
+ data := &IndexData{
+ Dir: filepath.Join(base, urlPath),
+ Entries: entries,
+ Parents: parentEntries,
+ }
+
+ response.WriteHeader(http.StatusOK)
+ if err := indexTemplate.Execute(response, data); err != nil {
+ fmt.Printf("trouble executing template: %s\n", err)
+ }
+ })
+}
+
+func (s *Server) Start() error {
+ mux := http.NewServeMux()
+
+ dir := http.Dir(s.dir)
+ mux.Handle("/", http.FileServer(dir))
+
+ handler := withIndex(string(dir), s.dot, http.Handler(mux))
+ if !s.dot {
+ handler = excludeDot(handler)
+ }
+ if s.cors {
+ handler = cors.Default().Handler(handler)
+ }
+
+ fmt.Printf("Serving %s on http://localhost:%d/\n", s.dir, s.port)
+ return http.ListenAndServe(fmt.Sprintf(":%d", s.port), handler)
+}
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..c38c59c
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,16 @@
+# host
+
+Serve files via HTTP.
+
+```
+Usage: host
+
+Arguments:
+ Serve files from this directory.
+
+Flags:
+ -h, --help Show context-sensitive help.
+ --port=4000 Listen on this port.
+ --[no-]cors Include CORS support (on by default).
+ --dot Serve dot files (files prefixed with a '.')
+```