commit 640b38ea5f166aa44f87db4ec2a4c24ba55a46c9 Author: Tordarus Date: Fri Jan 14 16:42:23 2022 +0100 initial commit diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3732b44 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.tordarus.net/Tordarus/httpfs + +go 1.17 + +require github.com/msteinert/pam v1.0.0 + +require github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9b8ced3 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b h1:FQ7+9fxhyp82ks9vAuyPzG0/vVbWwMwLJ+P6yJI5FN8= +github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b/go.mod h1:HMcgvsgd0Fjj4XXDkbjdmlbI505rUPBs6WBMYg2pXks= +github.com/msteinert/pam v1.0.0 h1:4XoXKtMCH3+e6GIkW41uxm6B37eYqci/DH3gzSq7ocg= +github.com/msteinert/pam v1.0.0/go.mod h1:M4FPeAW8g2ITO68W8gACDz13NDJyOQM9IQsQhrR6TOI= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/httpfs b/httpfs new file mode 100755 index 0000000..aa8af76 Binary files /dev/null and b/httpfs differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..a3f3571 --- /dev/null +++ b/main.go @@ -0,0 +1,263 @@ +package main + +import ( + "encoding/base64" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +var ( + directory = flag.String("d", ".", "directory") + intf = flag.String("i", "", "interface") + port = flag.Uint("p", 80, "port") + noauth = flag.Bool("-no-auth", false, "dont use basic auth") +) + +var ( + fileServer http.Handler +) + +func main() { + flag.Parse() + + fileServer = http.FileServer(http.Dir(*directory)) + http.HandleFunc("/", handler) + + err := http.ListenAndServe(fmt.Sprintf("%s:%d", *intf, *port), nil) + if err != nil { + panic(err) + } +} + +func handler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + get(w, r) + case "PUT": + put(w, r) // TODO accept Range header for PUT requests + case "HEAD": + head(w, r) + case "DELETE": + delete(w, r) + case "COPY": + copy(w, r) + case "LINK": + link(w, r) + } +} + +func get(w http.ResponseWriter, r *http.Request) { + path := getPath(r.URL.Path, r) + + fi, err := os.Stat(path) + if err != nil { + handleError(w, r, err) + return + } + + w.Header().Add("Content-Type", getMimetype(path)) + w.Header().Add("File-Permissions", fi.Mode().Perm().String()) + w.Header().Add("Modified-Time", fi.ModTime().String()) + + username, groupname, err := getOwnerNames(getOwnerIDs(fi)) + if err == nil { + w.Header().Add("File-Owner", username) + w.Header().Add("Group-Owner", groupname) + } + + if fi.IsDir() { + files, err := os.ReadDir(path) + if err != nil { + handleError(w, r, err) + return + } + + fmt.Println(path, files) + + for _, file := range files { + fmt.Fprintln(w, url.QueryEscape(file.Name())) + } + return + } + + fileServer.ServeHTTP(w, r) +} + +func put(w http.ResponseWriter, r *http.Request) { + path := getPath(r.URL.Path, r) + + if r.Header.Get("Content-Type") == "inode/directory" { + err := os.MkdirAll(path, os.ModePerm) + if err != nil { + handleError(w, r, err) + } + return + } + + err := os.MkdirAll(filepath.Dir(path), os.ModePerm) + if err != nil { + handleError(w, r, err) + return + } + + file, err := os.Create(path) + if err != nil { + handleError(w, r, err) + return + } + defer file.Close() + + io.Copy(file, r.Body) +} + +func head(w http.ResponseWriter, r *http.Request) { + path := getPath(r.URL.Path, r) + + fi, err := os.Stat(path) + if err != nil { + handleError(w, r, err) + return + } + + w.Header().Add("Content-Type", getMimetype(path)) + w.Header().Add("File-Permissions", fi.Mode().Perm().String()) + w.Header().Add("Modified-Time", fi.ModTime().String()) + + username, groupname, err := getOwnerNames(getOwnerIDs(fi)) + if err == nil { + w.Header().Add("File-Owner", username) + w.Header().Add("Group-Owner", groupname) + } + + fileServer.ServeHTTP(w, r) +} + +func delete(w http.ResponseWriter, r *http.Request) { + path := getPath(r.URL.Path, r) + err := os.RemoveAll(path) + if err != nil { + handleError(w, r, err) + return + } +} + +func copy(w http.ResponseWriter, r *http.Request) { + destinations, ok := r.Header["Destination"] + + if !ok { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, "missing Destination header") + } + + current_path := getPath(r.URL.Path, r) + + destination_paths := make([]string, 0, len(destinations)) + for _, destination := range destinations { + destination_paths = append(destination_paths, getPath(destination, r)) + } + + current_file, err := os.Open(current_path) + if err != nil { + handleError(w, r, err) + return + } + defer current_file.Close() + + destination_files := make([]io.Writer, 0, len(destination_paths)) + for _, destination_path := range destination_paths { + destination_file, err := os.Create(destination_path) + if err != nil { + handleError(w, r, err) + return + } + defer destination_file.Close() + + destination_files = append(destination_files, destination_file) + } + + io.Copy(io.MultiWriter(destination_files...), current_file) +} + +func link(w http.ResponseWriter, r *http.Request) { + _, ok := r.Header["Link"] + + if !ok { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, "missing Destination header") + } + + current_path := getPath(r.URL.Path, r) + destination_path := getPath(r.Header.Get("Link"), r) + + err := os.Symlink(current_path, destination_path) + if err != nil { + handleError(w, r, err) + return + } +} + +func handleError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, os.ErrNotExist) { + w.WriteHeader(http.StatusNotFound) + } else if errors.Is(err, errInvalidRange) { + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + } else { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, err) + } +} + +func getOwnerIDs(fi fs.FileInfo) (uid, gid string) { + if stat, ok := fi.Sys().(*syscall.Stat_t); ok { + return strconv.Itoa(int(stat.Uid)), strconv.Itoa(int(stat.Gid)) + } + + return "", "" +} + +func getOwnerNames(uid, gid string) (username, groupname string, err error) { + usr, err := user.LookupId(uid) + if err != nil { + return "", "", err + } + + grp, err := user.LookupGroupId(gid) + if err != nil { + return "", "", err + } + + return usr.Username, grp.Name, nil +} + +func getPath(dirty_path string, r *http.Request) string { + if *noauth { + return filepath.Join(*directory, filepath.Clean(dirty_path)) + } + + username, _, _ := basicAuth(r.Header.Get("Authorization")) + return filepath.Join(*directory, filepath.Clean(username), filepath.Clean(dirty_path)) +} + +func basicAuth(authHeader string) (username, password string, err error) { + data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic ")) + if err != nil { + return "", "", err + } + + splits := strings.SplitN(string(data), ":", 2) + if len(splits) < 2 { + return "", "", errors.New("invalid auth header") + } + return splits[0], splits[1], nil +} diff --git a/mimetypes.go b/mimetypes.go new file mode 100644 index 0000000..2f88ac7 --- /dev/null +++ b/mimetypes.go @@ -0,0 +1,96 @@ +package main + +import ( + "os/exec" + "path/filepath" + "strings" +) + +var mimetypes = map[string]string{ + ".aac": "audio/aac", + ".abw": "application/x-abiword", + ".arc": "application/x-freearc", + ".avi": "video/x-msvideo", + ".azw": "application/vnd.amazon.ebook", + ".bin": "application/octet-stream", + ".bmp": "image/bmp", + ".bz": "application/x-bzip", + ".bz2": "application/x-bzip2", + ".cda": "application/x-cdf", + ".csh": "application/x-csh", + ".css": "text/css", + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".eot": "application/vnd.ms-fontobject", + ".epub": "application/epub+zip", + ".gz": "application/gzip", + ".gif": "image/gif", + ".htm": "text/html", + ".html": "text/html", + ".ico": "image/vnd.microsoft.icon", + ".ics": "text/calendar", + ".jar": "application/java-archive", + ".jpeg": ".jpg image/jpeg", + ".js": "text/javascript", + ".json": "application/json", + ".jsonld": "application/ld+json", + ".mid": "audio/midi", + ".midi": "audio/midi", + ".mjs": "text/javascript", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".mpeg": "video/mpeg", + ".mpkg": "application/vnd.apple.installer+xml", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".odt": "application/vnd.oasis.opendocument.text", + ".oga": "audio/ogg", + ".ogv": "video/ogg", + ".ogx": "application/ogg", + ".opus": "audio/opus", + ".otf": "font/otf", + ".png": "image/png", + ".pdf": "application/pdf", + ".php": "application/x-httpd-php", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".rar": "application/vnd.rar", + ".rtf": "application/rtf", + ".sh": "application/x-sh", + ".svg": "image/svg+xml", + ".swf": "application/x-shockwave-flash", + ".tar": "application/x-tar", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".ts": "video/mp2t", + ".ttf": "font/ttf", + ".txt": "text/plain", + ".vsd": "application/vnd.visio", + ".wav": "audio/wav", + ".weba": "audio/webm", + ".webm": "video/webm", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".xhtml": "application/xhtml+xml", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xml": "application/xml", + ".xul": "application/vnd.mozilla.xul+xml", + ".zip": "application/zip", + ".7z": "application/x-7z-compressed", +} + +func getMimetype(path string) string { + cmd := exec.Command("file", "--mime-type", path) + data, err := cmd.CombinedOutput() + if err != nil { + if mimetype, ok := mimetypes[filepath.Ext(path)]; ok { + return mimetype + } + return "text/plain" + } + + return strings.Split(string(data), ": ")[1] +} diff --git a/range.go b/range.go new file mode 100644 index 0000000..9fe267d --- /dev/null +++ b/range.go @@ -0,0 +1,106 @@ +package main + +import ( + "errors" + "net/textproto" + "strconv" + "strings" +) + +// errNoOverlap is returned by serveContent's parseRange if first-byte-pos of +// all of the byte-range-spec values is greater than the content size. +var errInvalidRange = errors.New("invalid range") + +// httpRange specifies the byte range to be sent to the client. +type httpRange struct { + start, length int64 +} + +// parseRange parses a Range header string as per RFC 7233. +// errNoOverlap is returned if none of the ranges overlap. +func parseRange(s string, size int64) ([]httpRange, error) { + if s == "" { + return nil, nil // header not present + } + const b = "bytes=" + if !strings.HasPrefix(s, b) { + return nil, errInvalidRange + } + var ranges []httpRange + noOverlap := false + for _, ra := range strings.Split(s[len(b):], ",") { + ra = textproto.TrimString(ra) + if ra == "" { + continue + } + start, end, ok := Cut(ra, "-") + if !ok { + return nil, errInvalidRange + } + start, end = textproto.TrimString(start), textproto.TrimString(end) + var r httpRange + if start == "" { + // If no start is specified, end specifies the + // range start relative to the end of the file, + // and we are dealing with + // which has to be a non-negative integer as per + // RFC 7233 Section 2.1 "Byte-Ranges". + if end == "" || end[0] == '-' { + return nil, errInvalidRange + } + i, err := strconv.ParseInt(end, 10, 64) + if i < 0 || err != nil { + return nil, errInvalidRange + } + if i > size { + i = size + } + r.start = size - i + r.length = size - r.start + } else { + i, err := strconv.ParseInt(start, 10, 64) + if err != nil || i < 0 { + return nil, errInvalidRange + } + if i >= size { + // If the range begins after the size of the content, + // then it does not overlap. + noOverlap = true + continue + } + r.start = i + if end == "" { + // If no end is specified, range extends to end of the file. + r.length = size - r.start + } else { + i, err := strconv.ParseInt(end, 10, 64) + if err != nil || r.start > i { + return nil, errInvalidRange + } + if i >= size { + i = size - 1 + } + r.length = i - r.start + 1 + } + } + ranges = append(ranges, r) + } + if noOverlap && len(ranges) == 0 { + // The specified ranges did not overlap with the content. + return nil, errInvalidRange + } + return ranges, nil +} + +// Cut slices s around the first instance of sep, +// returning the text before and after sep. +// The found result reports whether sep appears in s. +// If sep does not appear in s, cut returns s, nil, false. +// +// Cut returns slices of the original slice s, not copies. +func Cut(s, sep string) (before, after string, found bool) { + if i := strings.Index(s, sep); i >= 0 { + return s[:i], s[i+len(sep):], true + } + return s, "", false +} diff --git a/test2.txt b/test2.txt new file mode 100644 index 0000000..e69de29