// Command xf (xfer) is a single-file HTTP file transfer server. Every // directory under the served root renders as one embedded web page that lists // its entries and accepts uploads into that directory; every file is served // for download. // // Usage: // // xf [-addr address] [-dir directory] [-max-upload bytes] package main import ( "crypto/rand" "encoding/hex" "errors" "flag" "fmt" "html/template" "io" "log" "net" "net/http" "net/url" "os" "path" "path/filepath" "sort" "strings" "syscall" "time" ) const ( defaultMaxUpload = 1 << 30 maxUploadParts = 1000 tempPrefix = ".xf-upload-" // readHeaderTimeout bounds a slow client without bounding the time it // takes to stream a large upload body. readHeaderTimeout = 10 * time.Second ) var page = template.Must(template.New("index").Parse(indexHTML)) // access logs downloads and uploads to standard output, kept separate from the // startup and error diagnostics the default logger writes to standard error. var access = log.New(os.Stdout, "", log.LstdFlags) type server struct { root *os.Root maxUpload int64 } type stagedFile struct { name string temp string size int64 } // countedBody counts the bytes read from the request body. multipart can turn // a MaxBytesReader error into a parse error, so the count, not the error, // decides whether an upload was refused for its size. type countedBody struct { io.ReadCloser bytes int64 } func (body *countedBody) Read(p []byte) (int, error) { n, err := body.ReadCloser.Read(p) body.bytes += int64(n) return n, err } type entry struct { Name string Href string Size string IsDir bool } type pageData struct { Dir string // URL path of the listed directory Parent bool // whether to link to the parent directory Entries []entry } func main() { addr := flag.String("addr", "127.0.0.1:8080", "listen address") dir := flag.String("dir", ".", "directory to serve") maxUpload := flag.Int64("max-upload", defaultMaxUpload, "maximum bytes in one upload request") flag.Parse() if *maxUpload <= 0 { log.Fatal("xf: -max-upload must be greater than zero") } rootName, err := filepath.Abs(*dir) if err != nil { log.Fatalf("xf: %v", err) } root, err := os.OpenRoot(rootName) if err != nil { log.Fatalf("xf: %v", err) } s := &server{root: root, maxUpload: *maxUpload} mux := http.NewServeMux() mux.HandleFunc("GET /", s.handleGet) mux.HandleFunc("POST /", s.handleUpload) srv := &http.Server{ Addr: *addr, Handler: mux, ReadHeaderTimeout: readHeaderTimeout, } log.Printf("xf: serving %s on %s", rootName, *addr) log.Fatalf("xf: %v", srv.ListenAndServe()) } // resolve maps a request path to a path relative to the server root. func (s *server) resolve(urlPath string) string { name := strings.TrimPrefix(path.Clean("/"+urlPath), "/") if name == "" { return "." } return filepath.FromSlash(name) } func (s *server) handleGet(w http.ResponseWriter, r *http.Request) { rec := &recorder{ResponseWriter: w, status: http.StatusOK} s.serve(rec, r) access.Printf("get %q -> %d (%d bytes) from %s", r.URL.Path, rec.status, rec.bytes, clientIP(r)) } func (s *server) serve(w http.ResponseWriter, r *http.Request) { name := s.resolve(r.URL.Path) if isTempName(filepath.Base(name)) { http.NotFound(w, r) return } // O_NONBLOCK so that opening a FIFO cannot hang the handler; non-regular // files are refused below. f, err := s.root.OpenFile(name, os.O_RDONLY|syscall.O_NONBLOCK, 0) switch { case os.IsNotExist(err): http.NotFound(w, r) return case os.IsPermission(err): http.Error(w, "forbidden", http.StatusForbidden) return case err != nil: // A path the root refuses, such as an escaping symlink or a // component that is not a directory, is not a server fault. log.Printf("xf: open %q: %v", r.URL.Path, err) http.NotFound(w, r) return } defer f.Close() fi, err := f.Stat() if err != nil { log.Printf("xf: stat %q: %v", r.URL.Path, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if fi.IsDir() { // Directory pages need a trailing slash so relative links work. if !strings.HasSuffix(r.URL.Path, "/") { http.Redirect(w, r, path.Clean(r.URL.Path)+"/", http.StatusMovedPermanently) return } s.list(w, f, r.URL.Path) return } if !fi.Mode().IsRegular() { http.Error(w, "unsupported file type", http.StatusForbidden) return } http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) } func (s *server) list(w http.ResponseWriter, dir *os.File, urlPath string) { dirents, err := dir.ReadDir(-1) if err != nil { log.Printf("xf: list %q: %v", urlPath, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } sort.Slice(dirents, func(i, j int) bool { if dirents[i].IsDir() != dirents[j].IsDir() { return dirents[i].IsDir() } return dirents[i].Name() < dirents[j].Name() }) entries := make([]entry, 0, len(dirents)) for _, de := range dirents { if isTempName(de.Name()) { continue } e := entry{ Name: de.Name(), Href: (&url.URL{Path: de.Name()}).String(), IsDir: de.IsDir(), } if de.IsDir() { e.Href += "/" e.Size = "-" } else if fi, err := de.Info(); err == nil { e.Size = humanSize(fi.Size()) } else { e.Size = "?" } entries = append(entries, e) } w.Header().Set("Content-Type", "text/html; charset=utf-8") data := pageData{Dir: urlPath, Parent: urlPath != "/", Entries: entries} if err := page.Execute(w, data); err != nil { log.Printf("xf: render: %v", err) } } // handleUpload stages every file in the request before renaming any into // place, so a refused or interrupted upload leaves the directory untouched. func (s *server) handleUpload(w http.ResponseWriter, r *http.Request) { dir, err := s.root.OpenRoot(s.resolve(r.URL.Path)) if err != nil { http.Error(w, "not a directory", http.StatusNotFound) return } defer dir.Close() if r.ContentLength > s.maxUpload { http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) return } body := &countedBody{ReadCloser: r.Body} r.Body = http.MaxBytesReader(w, body, s.maxUpload) mr, err := r.MultipartReader() if err != nil { http.Error(w, "bad upload: "+err.Error(), http.StatusBadRequest) return } var files []stagedFile defer func() { for _, file := range files { dir.Remove(file.temp) } }() names := make(map[string]bool) parts := 0 for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { writeUploadError(w, err, http.StatusBadRequest, body.bytes > s.maxUpload) return } parts++ if parts > maxUploadParts { http.Error(w, "too many upload parts", http.StatusBadRequest) return } if part.FileName() == "" { continue // not a file part } // The client supplied name is reduced to its base component so an // upload can never escape the directory. name := filepath.Base(part.FileName()) if name == "/" || name == "." || name == ".." || isTempName(name) { http.Error(w, fmt.Sprintf("invalid filename: %q", part.FileName()), http.StatusBadRequest) return } if names[name] { http.Error(w, fmt.Sprintf("duplicate filename: %q", name), http.StatusBadRequest) return } names[name] = true file, err := stage(dir, name, part) if err != nil { status := http.StatusInternalServerError if errors.Is(err, io.ErrUnexpectedEOF) { status = http.StatusBadRequest } writeUploadError(w, err, status, body.bytes > s.maxUpload) return } files = append(files, file) } if len(files) == 0 { http.Error(w, "no files in upload", http.StatusBadRequest) return } for _, file := range files { if err := dir.Rename(file.temp, file.name); err != nil { log.Printf("xf: commit upload %q: %v", file.name, err) http.Error(w, "upload failed", http.StatusInternalServerError) return } access.Printf("upload %q (%d bytes) from %s", path.Join(r.URL.Path, file.name), file.size, clientIP(r)) } http.Redirect(w, r, r.URL.Path, http.StatusSeeOther) } // stage streams one upload into a temporary file in its target directory, so // that the commit is an atomic rename. func stage(dir *os.Root, name string, src io.Reader) (stagedFile, error) { mode := os.FileMode(0o600) fi, err := dir.Lstat(name) switch { case os.IsNotExist(err): case err != nil: return stagedFile{}, err case !fi.Mode().IsRegular(): return stagedFile{}, fmt.Errorf("refusing to replace non-regular file %q", name) default: mode = fi.Mode().Perm() } var random [16]byte if _, err := rand.Read(random[:]); err != nil { return stagedFile{}, err } tmpName := tempPrefix + hex.EncodeToString(random[:]) tmp, err := dir.OpenFile(tmpName, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return stagedFile{}, err } n, err := io.Copy(tmp, src) if err == nil { err = tmp.Chmod(mode) } if cerr := tmp.Close(); err == nil { err = cerr } if err != nil { dir.Remove(tmpName) return stagedFile{}, err } return stagedFile{name: name, temp: tmpName, size: n}, nil } func isTempName(name string) bool { if len(name) != len(tempPrefix)+32 || !strings.HasPrefix(name, tempPrefix) { return false } _, err := hex.DecodeString(strings.TrimPrefix(name, tempPrefix)) return err == nil } func writeUploadError(w http.ResponseWriter, err error, status int, tooLarge bool) { switch { case tooLarge: http.Error(w, "upload too large", http.StatusRequestEntityTooLarge) case status == http.StatusInternalServerError: log.Printf("xf: upload: %v", err) http.Error(w, "upload failed", status) default: http.Error(w, "bad upload: "+err.Error(), status) } } // recorder captures the status and size of a response for the access log. type recorder struct { http.ResponseWriter status int bytes int64 } func (rec *recorder) WriteHeader(code int) { rec.status = code rec.ResponseWriter.WriteHeader(code) } func (rec *recorder) Write(p []byte) (int, error) { n, err := rec.ResponseWriter.Write(p) rec.bytes += int64(n) return n, err } func clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func humanSize(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for x := n / unit; x >= unit; x /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) } const indexHTML = ` xf

xf: {{.Dir}}

{{if .Parent}} {{end}} {{range .Entries}} {{else}} {{end}}
../ -
{{.Name}}{{if .IsDir}}/{{end}} {{.Size}}
empty
`