diff options
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 453 |
1 files changed, 453 insertions, 0 deletions
@@ -0,0 +1,453 @@ +// 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 = `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>xf</title> +<style> +body { font-family: ui-monospace, monospace; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; color: #111; } +h1 { font-size: 1rem; font-weight: normal; color: #555; word-break: break-all; } +form { margin: 1.5rem 0; padding: 1rem; border: 1px solid #ccc; } +table { width: 100%; border-collapse: collapse; } +td { text-align: left; padding: 0.3rem 0.5rem; border-bottom: 1px solid #eee; } +td.size { text-align: right; white-space: nowrap; color: #555; } +a { text-decoration: none; } +a:hover { text-decoration: underline; } +.empty { color: #999; } +</style> +</head> +<body> +<h1>xf: {{.Dir}}</h1> +<form method="post" enctype="multipart/form-data"> +<input type="file" name="files" multiple required> +<button type="submit">upload</button> +</form> +<table> +<tbody> +{{if .Parent}} +<tr> +<td><a href="../">../</a></td> +<td class="size">-</td> +</tr> +{{end}} +{{range .Entries}} +<tr> +<td><a href="{{.Href}}">{{.Name}}{{if .IsDir}}/{{end}}</a></td> +<td class="size">{{.Size}}</td> +</tr> +{{else}} +<tr><td class="empty" colspan="2">empty</td></tr> +{{end}} +</tbody> +</table> +</body> +</html> +` |