commit 68b2ec5f5a3a550c963485656225dac21902d604 Author: Tordarus Date: Fri Jun 6 16:09:28 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31801d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +storage +.env diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb0030d --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.tordarus.net/tordarus/httpfs + +go 1.20 + +require ( + git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5 + git.tordarus.net/tordarus/slices v0.0.14 +) + +require git.tordarus.net/tordarus/gmath v0.0.7 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2890a56 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5 h1:rKNDX/YGunqg8TEU6q1rgS2BcDKVmUW2cg61JOE/wws= +git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5/go.mod h1:/qVGwrEmqtIrZyuuoIQl4vquSkPWUNJmlGNedDrdYfg= +git.tordarus.net/tordarus/gmath v0.0.7 h1:tR48idt9AUL0r556ww3ZxByTKJEr6NWCTlhl2ihzYxQ= +git.tordarus.net/tordarus/gmath v0.0.7/go.mod h1:mO7aPlvNrGVE9UFXEuuACjZgMDsM63l3OcQy6xSQnoE= +git.tordarus.net/tordarus/slices v0.0.14 h1:Jy1VRMs777WewJ7mxTgjyQIMm/Zr+co18/XoQ01YZ3A= +git.tordarus.net/tordarus/slices v0.0.14/go.mod h1:RgE7A1aSAezIvPUgcbUuMHu0q4xGKoRevT+DC0eJmwI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..22f539b --- /dev/null +++ b/main.go @@ -0,0 +1,225 @@ +package main + +import ( + "errors" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + + "git.tordarus.net/tordarus/envvars" + "git.tordarus.net/tordarus/slices" +) + +var ( + Directory = envvars.String("ROOT_DIRECTORY", ".") + Interface = envvars.String("HTTP_INTERFACE", "") + Port = envvars.Uint16("HTTP_PORT", 80) + NoAuth = envvars.Bool("NO_AUTH", false) + AllowedMethods = envvars.StringSlice("HTTP_METHODS", ",", []string{"GET", "PUT", "HEAD", "DELETE", "COPY", "LINK"}) +) + +func main() { + fmt.Println("Root Directory:", Directory) + fmt.Println("HTTP interface:", Interface) + fmt.Println("HTTP port:", Port) + fmt.Println("Basic Auth enabled:", !NoAuth) + fmt.Println("Allowed HTTP methods:", strings.Join(AllowedMethods, ",")) + + http.HandleFunc("/", handler) + + err := http.ListenAndServe(fmt.Sprintf("%s:%d", Interface, Port), nil) + if err != nil { + panic(err) + } +} + +func handler(w http.ResponseWriter, r *http.Request) { + log.Println(r.Method, getPath(r.URL.Path, r)) + + if !slices.Contains(AllowedMethods, r.Method) { + w.Header().Add("Allow", strings.Join(AllowedMethods, ", ")) + http.Error(w, "invalid method", http.StatusMethodNotAllowed) + return + } + + 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) + http.ServeFile(w, r, path) +} + +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) + } +} + +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, _, _ := r.BasicAuth() + return filepath.Join(Directory, filepath.Clean(username), filepath.Clean(dirty_path)) +} 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 +}