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}} +

+
+ + + + 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 '.') +```