package glaze import ( "context" "errors" "flag" "fmt" "io" "io/fs" "log/slog" "net" "net/http" "net/url" "os" "slices" "strings" "syscall" "time" "uuid" g "gobang" gt "gotext" ) type patternPath struct{} type pathInfo struct{ pattern string path string label string fileHandle *os.File fileInfo fs.FileInfo } const ( xattrTryIndexHtml = "user.glaze.try-index-html" xattrDirectoryListing = "user.glaze.directory-listing" xattrExpectedValue = "true" ) func httpError(w http.ResponseWriter, code int, err error) { t := http.StatusText(code) g.Error( err.Error(), "http-server-error", "code", code, "error", err, "status-text", t, ) http.Error(w, t, code) } func withTrailingSlash(path string) string { if strings.HasSuffix(path, "/") { return path } else { return path + "/" } } func adjustPattern(pattern string) string { if pattern == "*" { return "/" } else if strings.HasSuffix(pattern, "*") { return pattern[0:len(pattern) - 1] } else { return pattern + "{$}" } } func handleSymlink( info pathInfo, w http.ResponseWriter, r *http.Request, ) error { linked, err := os.Readlink(info.path) if err != nil { return err } // From http.Redirect(): // // > If the Content-Type header has not been set, // > Redirect sets it to "text/html; charset=utf-8" and // > writes a small HTML body. // // 🙄 w.Header().Set("Content-Type", "text/plain; charset=utf-8") http.Redirect(w, r, linked, http.StatusMovedPermanently) return nil } func handleProxy(info pathInfo, w http.ResponseWriter, r *http.Request) error { target, err := url.Parse(info.path) if err != nil { return err } target.Scheme = "http" target.Host = "localhost" httpClient := http.Client{ Transport: &http.Transport{ DialContext: func( _ context.Context, _ string, _ string, ) (net.Conn, error) { return net.Dial("unix", info.path) }, }, } r.URL.Scheme = target.Scheme r.URL.Host = target.Host r.RequestURI = "" response, err := httpClient.Do(r) if err != nil { return err } for k, vArr := range response.Header { for _, v := range vArr { w.Header().Add(k, v) } } w.WriteHeader(response.StatusCode) io.Copy(w, response.Body) return nil } func handleFile(info pathInfo, w http.ResponseWriter, r *http.Request) error { http.ServeContent( w, r, info.fileInfo.Name(), info.fileInfo.ModTime(), info.fileHandle, ) return nil } func getXattr(path string, name string) (string, error) { data := make([]byte, 256) i, err := syscall.Getxattr(path, name, data) if err != nil { return "", err } return string(data[0:i]), nil } func handleDirectoryTarget( info pathInfo, w http.ResponseWriter, r *http.Request, ) error { targetPath := withTrailingSlash(info.path) + r.URL.Path newInfo, fn, err := handlerForDynPath(targetPath) if err != nil { is404 := errors.Is(err, fs.ErrNotExist ) || errors.Is(err, fs.ErrPermission) if is404 { httpError(w, http.StatusNotFound, err) return nil } return err } if (newInfo.label != "directory") { return fn(newInfo, w, r) } indexHtmlXattr, err := getXattr(targetPath, xattrTryIndexHtml) if err == nil && string(indexHtmlXattr) == xattrExpectedValue { newPath := withTrailingSlash(targetPath) + "index.html" _, err := os.Open(newPath) if err == nil { return fn(newInfo, w, r) } } dirListXattr, err := getXattr(targetPath, xattrDirectoryListing) if err == nil && string(dirListXattr) == xattrExpectedValue { http.FileServer(http.Dir(newInfo.path)).ServeHTTP(w, r) return nil } http.Error(w, "Forbidden", http.StatusForbidden) return nil } func handlerFuncFor( mode fs.FileMode, ) (string, func(pathInfo, http.ResponseWriter, *http.Request) error) { if mode.Type() == fs.ModeSymlink { return "symlink", handleSymlink } else if mode.Type() == fs.ModeSocket { return "socket", handleProxy } else if !mode.IsDir() { return "file", handleFile } else { return "directory", handleDirectoryTarget } } func handlerForDynPath( path string, ) (pathInfo, func(pathInfo, http.ResponseWriter, *http.Request) error, error) { var info pathInfo fileHandle, err := os.Open(path) if err != nil { return info, nil, err } fileInfo, err := os.Lstat(path) if err != nil { return info, nil, err } label, fn := handlerFuncFor(fileInfo.Mode()) g.Info("Handler picked", "handler-picked", "label", label) info = pathInfo{ path: path, label: label, fileHandle: fileHandle, fileInfo: fileInfo, } return info, fn, nil } func logged( pattern string, path string, handler http.HandlerFunc, ) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() id := uuid.New().String() args := []any{ "id", id, "pattern", []string{ pattern, path }, "url-path", r.URL.Path, "req-pattern", r.Pattern, "method", r.Method, "host", r.Host, "uri", r.RequestURI, } g.Info( "in-request", "in-request", args..., ) handler(w, r) durationNano := time.Since(start) durationMilli := float64(durationNano) / float64(time.Millisecond) g.Info( "in-response", "in-response", slices.Concat( []any{ "duration-ns", durationNano, "duration-ms", durationMilli, }, args, )..., ) }) } func handlerFor(path string) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { info, fn, err := handlerForDynPath(path) if err != nil { httpError(w, http.StatusInternalServerError, err) return } err = fn(info, w, r) if err != nil { httpError(w, http.StatusInternalServerError, err) return } }) } func (_ *patternPath) String() string { return "" } func (_ *patternPath) Set(value string) error { arr := strings.Split(value, ":") if len(arr) != 2 { return errors.New("Bad value for path pattern: " + value) } pattern := adjustPattern(arr[0]) path := arr[1] http.Handle( pattern, http.StripPrefix( pattern, logged(pattern, path, handlerFor(path)), ), ) return nil } func parseArgs(args []string) string { var pat patternPath fs := flag.NewFlagSet(args[0], flag.ExitOnError) fs.Var(&pat, "P", "") fs.Parse(args[1:]) if fs.NArg() != 1 { fmt.Fprintf( os.Stderr, gt.Gettext("Usage: %s [ -P PATTERN:PATH ]... LISTEN.socket\n"), args[0], ) os.Exit(2) } return fs.Arg(0) } func listen(fromAddr string) net.Listener { listener, err := net.Listen("unix", fromAddr) g.FatalIf(err) g.Info( "Started listening", "listen-start", "from-address", fromAddr, slog.Group( "versions", "uuid", uuid.Version, "gobang", g.Version, "glaze", Version, ), ) return listener } func start(listener net.Listener) { server := http.Server{} err := server.Serve(listener) g.FatalIf(err) } func Main() { g.Init("program", "glaze") addr := parseArgs(os.Args) listener := listen(addr) start(listener) }