From 89550e712144d1a60c146c4c62fd471d028d11ab Mon Sep 17 00:00:00 2001
From: Nick Craig-Wood <nick@craig-wood.com>
Date: Sun, 28 Oct 2018 14:31:24 +0000
Subject: [PATCH] rcserver: serve directories as well as files

---
 cmd/rcd/rcd.go                              |   2 +
 cmd/serve/httplib/serve/serve.go            |   9 +
 docs/content/rc.md                          |  70 ++-
 fs/rc/rc.go                                 |   3 +-
 fs/rc/rcflags/rcflags.go                    |   3 +-
 fs/rc/rcserver/rcserver.go                  | 108 +++-
 fs/rc/rcserver/rcserver_test.go             | 570 ++++++++++++++++++++
 fs/rc/rcserver/testdata/files/dir/file2.txt |   1 +
 fs/rc/rcserver/testdata/files/file.txt      |   1 +
 9 files changed, 722 insertions(+), 45 deletions(-)
 create mode 100644 fs/rc/rcserver/rcserver_test.go
 create mode 100644 fs/rc/rcserver/testdata/files/dir/file2.txt
 create mode 100644 fs/rc/rcserver/testdata/files/file.txt

diff --git a/cmd/rcd/rcd.go b/cmd/rcd/rcd.go
index 0f80c1b3d..ca77f2638 100644
--- a/cmd/rcd/rcd.go
+++ b/cmd/rcd/rcd.go
@@ -24,6 +24,8 @@ This is useful if you are controlling rclone via the rc API.
 If you pass in a path to a directory, rclone will serve that directory
 for GET requests on the URL passed in.  It will also open the URL in
 the browser when rclone is run.
+
+See the [rc documentation](/rc/) for more info on the rc flags.
 `,
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(0, 1, command, args)
diff --git a/cmd/serve/httplib/serve/serve.go b/cmd/serve/httplib/serve/serve.go
index cc16affc8..2241851e8 100644
--- a/cmd/serve/httplib/serve/serve.go
+++ b/cmd/serve/httplib/serve/serve.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"path"
 	"strconv"
 
 	"github.com/ncw/rclone/fs"
@@ -26,6 +27,14 @@ func Object(w http.ResponseWriter, r *http.Request, o fs.Object) {
 		w.Header().Set("Content-Length", strconv.FormatInt(o.Size(), 10))
 	}
 
+	// Set content type
+	mimeType := fs.MimeType(o)
+	if mimeType == "application/octet-stream" && path.Ext(o.Remote()) == "" {
+		// Leave header blank so http server guesses
+	} else {
+		w.Header().Set("Content-Type", mimeType)
+	}
+
 	if r.Method == "HEAD" {
 		return
 	}
diff --git a/docs/content/rc.md b/docs/content/rc.md
index 889005153..e4d40b38c 100644
--- a/docs/content/rc.md
+++ b/docs/content/rc.md
@@ -9,46 +9,78 @@ date: "2018-03-05"
 If rclone is run with the `--rc` flag then it starts an http server
 which can be used to remote control rclone.
 
+If you just want to run a remote control then see the [rcd command](/commands/rclone_rcd/).
+
 **NB** this is experimental and everything here is subject to change!
 
 ## Supported parameters
 
-#### --rc ####
+### --rc
+
 Flag to start the http server listen on remote requests
       
-#### --rc-addr=IP ####
+### --rc-addr=IP
+
 IPaddress:Port or :Port to bind server to. (default "localhost:5572")
 
-#### --rc-cert=KEY ####
+### --rc-cert=KEY
 SSL PEM key (concatenation of certificate and CA certificate)
 
-#### --rc-client-ca=PATH ####
+### --rc-client-ca=PATH
 Client certificate authority to verify clients with
 
-#### --rc-htpasswd=PATH ####
+### --rc-htpasswd=PATH
+
 htpasswd file - if not provided no authentication is done
 
-#### --rc-key=PATH ####
+### --rc-key=PATH
+
 SSL PEM Private key
 
-#### --rc-max-header-bytes=VALUE ####
+### --rc-max-header-bytes=VALUE
+
 Maximum size of request header (default 4096)
 
-#### --rc-user=VALUE ####
+### --rc-user=VALUE
+
 User name for authentication.
 
-#### --rc-pass=VALUE ####
+### --rc-pass=VALUE
+
 Password for authentication.
 
-#### --rc-realm=VALUE ####
+### --rc-realm=VALUE
+
 Realm for authentication (default "rclone")
 
-#### --rc-server-read-timeout=DURATION ####
+### --rc-server-read-timeout=DURATION
+
 Timeout for server reading data (default 1h0m0s)
 
-#### --rc-server-write-timeout=DURATION ####
+### --rc-server-write-timeout=DURATION
+
 Timeout for server writing data (default 1h0m0s)
 
+### --rc-serve
+
+Enable the serving of remote objects via the HTTP interface.  This
+means objects will be accessible at http://127.0.0.1:5572/ by default,
+so you can browse to http://127.0.0.1:5572/ or http://127.0.0.1:5572/*
+to see a listing of the remotes.  Objects may be requested from
+remotes using this syntax http://127.0.0.1:5572/[remote:path]/path/to/object
+
+Default Off.
+
+### --rc-files /path/to/directory
+
+Path to local files to serve on the HTTP server.
+
+If this is set then rclone will serve the files in that directory.  It
+will also open the root in the web browser if specified.  This is for
+implementing browser based GUIs for rclone functions.
+
+Default Off.
+
 ## Accessing the remote control via the rclone rc command
 
 Rclone itself implements the remote control protocol in its `rclone
@@ -394,7 +426,7 @@ The response to a preflight OPTIONS request will echo the requested "Access-Cont
 ### Using POST with URL parameters only
 
 ```
-curl -X POST 'http://localhost:5572/rc/noop/?potato=1&sausage=2'
+curl -X POST 'http://localhost:5572/rc/noop?potato=1&sausage=2'
 ```
 
 Response
@@ -409,7 +441,7 @@ Response
 Here is what an error response looks like:
 
 ```
-curl -X POST 'http://localhost:5572/rc/error/?potato=1&sausage=2'
+curl -X POST 'http://localhost:5572/rc/error?potato=1&sausage=2'
 ```
 
 ```
@@ -425,7 +457,7 @@ curl -X POST 'http://localhost:5572/rc/error/?potato=1&sausage=2'
 Note that curl doesn't return errors to the shell unless you use the `-f` option
 
 ```
-$ curl -f -X POST 'http://localhost:5572/rc/error/?potato=1&sausage=2'
+$ curl -f -X POST 'http://localhost:5572/rc/error?potato=1&sausage=2'
 curl: (22) The requested URL returned error: 400 Bad Request
 $ echo $?
 22
@@ -434,7 +466,7 @@ $ echo $?
 ### Using POST with a form
 
 ```
-curl --data "potato=1" --data "sausage=2" http://localhost:5572/rc/noop/
+curl --data "potato=1" --data "sausage=2" http://localhost:5572/rc/noop
 ```
 
 Response
@@ -450,7 +482,7 @@ Note that you can combine these with URL parameters too with the POST
 parameters taking precedence.
 
 ```
-curl --data "potato=1" --data "sausage=2" "http://localhost:5572/rc/noop/?rutabaga=3&sausage=4"
+curl --data "potato=1" --data "sausage=2" "http://localhost:5572/rc/noop?rutabaga=3&sausage=4"
 ```
 
 Response
@@ -467,7 +499,7 @@ Response
 ### Using POST with a JSON blob
 
 ```
-curl -H "Content-Type: application/json" -X POST -d '{"potato":2,"sausage":1}' http://localhost:5572/rc/noop/
+curl -H "Content-Type: application/json" -X POST -d '{"potato":2,"sausage":1}' http://localhost:5572/rc/noop
 ```
 
 response
@@ -483,7 +515,7 @@ This can be combined with URL parameters too if required.  The JSON
 blob takes precedence.
 
 ```
-curl -H "Content-Type: application/json" -X POST -d '{"potato":2,"sausage":1}' 'http://localhost:5572/rc/noop/?rutabaga=3&potato=4'
+curl -H "Content-Type: application/json" -X POST -d '{"potato":2,"sausage":1}' 'http://localhost:5572/rc/noop?rutabaga=3&potato=4'
 ```
 
 ```
diff --git a/fs/rc/rc.go b/fs/rc/rc.go
index 167947875..1f7dc5f0e 100644
--- a/fs/rc/rc.go
+++ b/fs/rc/rc.go
@@ -19,7 +19,8 @@ import (
 type Options struct {
 	HTTPOptions httplib.Options
 	Enabled     bool   // set to enable the server
-	Files       string // set to enable serving files
+	Serve       bool   // set to serve files from remotes
+	Files       string // set to enable serving files locally
 }
 
 // DefaultOpt is the default values used for Options
diff --git a/fs/rc/rcflags/rcflags.go b/fs/rc/rcflags/rcflags.go
index 1740fd3eb..0efc6c946 100644
--- a/fs/rc/rcflags/rcflags.go
+++ b/fs/rc/rcflags/rcflags.go
@@ -17,6 +17,7 @@ var (
 func AddFlags(flagSet *pflag.FlagSet) {
 	rc.AddOption("rc", &Opt)
 	flags.BoolVarP(flagSet, &Opt.Enabled, "rc", "", false, "Enable the remote control server.")
-	flags.StringVarP(flagSet, &Opt.Files, "rc-files", "", "", "Serve these files on the HTTP server.")
+	flags.StringVarP(flagSet, &Opt.Files, "rc-files", "", "", "Path to local files to serve on the HTTP server.")
+	flags.BoolVarP(flagSet, &Opt.Serve, "rc-serve", "", false, "Enable the serving of remote objects.")
 	httpflags.AddFlagsPrefix(flagSet, "rc-", &Opt.HTTPOptions)
 }
diff --git a/fs/rc/rcserver/rcserver.go b/fs/rc/rcserver/rcserver.go
index 350c26e8c..b58105848 100644
--- a/fs/rc/rcserver/rcserver.go
+++ b/fs/rc/rcserver/rcserver.go
@@ -5,11 +5,16 @@ import (
 	"encoding/json"
 	"mime"
 	"net/http"
+	"net/url"
+	"regexp"
+	"sort"
 	"strings"
 
 	"github.com/ncw/rclone/cmd/serve/httplib"
 	"github.com/ncw/rclone/cmd/serve/httplib/serve"
 	"github.com/ncw/rclone/fs"
+	"github.com/ncw/rclone/fs/config"
+	"github.com/ncw/rclone/fs/list"
 	"github.com/ncw/rclone/fs/rc"
 	"github.com/pkg/errors"
 	"github.com/skratchdot/open-golang/open"
@@ -18,7 +23,8 @@ import (
 // Start the remote control server if configured
 func Start(opt *rc.Options) {
 	if opt.Enabled {
-		s := newServer(opt)
+		// Serve on the DefaultServeMux so can have global registrations appear
+		s := newServer(opt, http.DefaultServeMux)
 		go s.serve()
 	}
 }
@@ -27,13 +33,13 @@ func Start(opt *rc.Options) {
 type server struct {
 	srv   *httplib.Server
 	files http.Handler
+	opt   *rc.Options
 }
 
-func newServer(opt *rc.Options) *server {
-	// Serve on the DefaultServeMux so can have global registrations appear
-	mux := http.DefaultServeMux
+func newServer(opt *rc.Options, mux *http.ServeMux) *server {
 	s := &server{
 		srv: httplib.NewServer(mux, &opt.HTTPOptions),
+		opt: opt,
 	}
 	mux.HandleFunc("/", s.handler)
 
@@ -89,7 +95,7 @@ func writeError(path string, in rc.Params, w http.ResponseWriter, err error, sta
 
 // handler reads incoming requests and dispatches them
 func (s *server) handler(w http.ResponseWriter, r *http.Request) {
-	path := strings.Trim(r.URL.Path, "/")
+	path := strings.TrimLeft(r.URL.Path, "/")
 
 	w.Header().Add("Access-Control-Allow-Origin", "*")
 
@@ -102,7 +108,7 @@ func (s *server) handler(w http.ResponseWriter, r *http.Request) {
 		s.handlePost(w, r, path)
 	case "OPTIONS":
 		s.handleOptions(w, r, path)
-	case "GET":
+	case "GET", "HEAD":
 		s.handleGet(w, r, path)
 	default:
 		writeError(path, nil, w, errors.Errorf("method %q not allowed", r.Method), http.StatusMethodNotAllowed)
@@ -111,23 +117,29 @@ func (s *server) handler(w http.ResponseWriter, r *http.Request) {
 }
 
 func (s *server) handlePost(w http.ResponseWriter, r *http.Request, path string) {
-	// Parse the POST and URL parameters into r.Form, for others r.Form will be empty value
-	err := r.ParseForm()
-	if err != nil {
-		writeError(path, nil, w, errors.Wrap(err, "failed to parse form/URL parameters"), http.StatusBadRequest)
-		return
+	contentType := r.Header.Get("Content-Type")
+
+	values := r.URL.Query()
+	if contentType == "application/x-www-form-urlencoded" {
+		// Parse the POST and URL parameters into r.Form, for others r.Form will be empty value
+		err := r.ParseForm()
+		if err != nil {
+			writeError(path, nil, w, errors.Wrap(err, "failed to parse form/URL parameters"), http.StatusBadRequest)
+			return
+		}
+		values = r.Form
 	}
 
 	// Read the POST and URL parameters into in
 	in := make(rc.Params)
-	for k, vs := range r.Form {
+	for k, vs := range values {
 		if len(vs) > 0 {
 			in[k] = vs[len(vs)-1]
 		}
 	}
 
 	// Parse a JSON blob from the input
-	if r.Header.Get("Content-Type") == "application/json" {
+	if contentType == "application/json" {
 		err := json.NewDecoder(r.Body).Decode(&in)
 		if err != nil {
 			writeError(path, in, w, errors.Wrap(err, "failed to read input JSON"), http.StatusBadRequest)
@@ -138,7 +150,7 @@ func (s *server) handlePost(w http.ResponseWriter, r *http.Request, path string)
 	// Find the call
 	call := rc.Calls.Get(path)
 	if call == nil {
-		writeError(path, in, w, errors.Errorf("couldn't find method %q", path), http.StatusMethodNotAllowed)
+		writeError(path, in, w, errors.Errorf("couldn't find method %q", path), http.StatusNotFound)
 		return
 	}
 
@@ -176,24 +188,72 @@ func (s *server) handleOptions(w http.ResponseWriter, r *http.Request, path stri
 	w.WriteHeader(http.StatusOK)
 }
 
-func (s *server) handleGet(w http.ResponseWriter, r *http.Request, path string) {
-	// if we have an &fs parameter we are serving from a different fs
-	fsName := r.URL.Query().Get("fs")
-	if fsName != "" {
-		f, err := rc.GetCachedFs(fsName)
+func (s *server) serveRoot(w http.ResponseWriter, r *http.Request) {
+	remotes := config.FileSections()
+	sort.Strings(remotes)
+	directory := serve.NewDirectory("")
+	directory.Title = "List of all rclone remotes."
+	q := url.Values{}
+	for _, remote := range remotes {
+		q.Set("fs", remote)
+		directory.AddEntry("["+remote+":]", true)
+	}
+	directory.Serve(w, r)
+}
+
+func (s *server) serveRemote(w http.ResponseWriter, r *http.Request, path string, fsName string) {
+	f, err := rc.GetCachedFs(fsName)
+	if err != nil {
+		writeError(path, nil, w, errors.Wrap(err, "failed to make Fs"), http.StatusInternalServerError)
+		return
+	}
+	if path == "" || strings.HasSuffix(path, "/") {
+		path = strings.Trim(path, "/")
+		entries, err := list.DirSorted(f, false, path)
 		if err != nil {
-			writeError(path, nil, w, errors.Wrap(err, "failed to make Fs"), http.StatusInternalServerError)
+			writeError(path, nil, w, errors.Wrap(err, "failed to list directory"), http.StatusInternalServerError)
 			return
 		}
+		// Make the entries for display
+		directory := serve.NewDirectory(path)
+		for _, entry := range entries {
+			_, isDir := entry.(fs.Directory)
+			directory.AddEntry(entry.Remote(), isDir)
+		}
+		directory.Serve(w, r)
+	} else {
 		o, err := f.NewObject(path)
 		if err != nil {
 			writeError(path, nil, w, errors.Wrap(err, "failed to find object"), http.StatusInternalServerError)
 			return
 		}
 		serve.Object(w, r, o)
-	} else if s.files == nil {
-		http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
-	} else {
-		s.files.ServeHTTP(w, r)
 	}
 }
+
+// Match URLS of the form [fs]/remote
+var fsMatch = regexp.MustCompile(`^\[(.*?)\](.*)$`)
+
+func (s *server) handleGet(w http.ResponseWriter, r *http.Request, path string) {
+	// Look to see if this has an fs in the path
+	match := fsMatch.FindStringSubmatch(path)
+	switch {
+	case match != nil && s.opt.Serve:
+		// Serve /[fs]/remote files
+		s.serveRemote(w, r, match[2], match[1])
+		return
+	case path == "*" && s.opt.Serve:
+		// Serve /* as the remote listing
+		s.serveRoot(w, r)
+		return
+	case s.files != nil:
+		// Serve the files
+		s.files.ServeHTTP(w, r)
+		return
+	case path == "" && s.opt.Serve:
+		// Serve the root as a remote listing
+		s.serveRoot(w, r)
+		return
+	}
+	http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+}
diff --git a/fs/rc/rcserver/rcserver_test.go b/fs/rc/rcserver/rcserver_test.go
new file mode 100644
index 000000000..69864f2c4
--- /dev/null
+++ b/fs/rc/rcserver/rcserver_test.go
@@ -0,0 +1,570 @@
+// +build go1.8
+
+package rcserver
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"regexp"
+	"testing"
+	"time"
+
+	_ "github.com/ncw/rclone/backend/local"
+	"github.com/ncw/rclone/fs/rc"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	testBindAddress = "localhost:51781"
+	testURL         = "http://" + testBindAddress + "/"
+	testFs          = "testdata/files"
+	remoteURL       = "[" + testFs + "]/" // initial URL path to fetch from that remote
+)
+
+// Test the RC server runs and we can do HTTP fetches from it.
+// We'll do the majority of the testing with the httptest framework
+func TestRcServer(t *testing.T) {
+	opt := rc.DefaultOpt
+	opt.HTTPOptions.ListenAddr = testBindAddress
+	opt.Enabled = true
+	opt.Serve = true
+	opt.Files = testFs
+	mux := http.NewServeMux()
+	rcServer := newServer(&opt, mux)
+	go rcServer.serve()
+	defer rcServer.srv.Close()
+
+	// Do the simplest possible test to check the server is alive
+	// Do it a few times to wait for the server to start
+	var resp *http.Response
+	var err error
+	for i := 0; i < 10; i++ {
+		resp, err = http.Get(testURL + "file.txt")
+		if err == nil {
+			break
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+
+	require.NoError(t, err)
+	body, err := ioutil.ReadAll(resp.Body)
+	_ = resp.Body.Close()
+
+	require.NoError(t, err)
+	require.NoError(t, resp.Body.Close())
+
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	assert.Equal(t, "this is file1.txt\n", string(body))
+}
+
+type testRun struct {
+	Name        string
+	URL         string
+	Status      int
+	Method      string
+	Range       string
+	Body        string
+	ContentType string
+	Expected    string
+	Contains    *regexp.Regexp
+	Headers     map[string]string
+}
+
+// Run a suite of tests
+func testServer(t *testing.T, tests []testRun, opt *rc.Options) {
+	mux := http.NewServeMux()
+	rcServer := newServer(opt, mux)
+	for _, test := range tests {
+		t.Run(test.Name, func(t *testing.T) {
+			method := test.Method
+			if method == "" {
+				method = "GET"
+			}
+			var inBody io.Reader
+			if test.Body != "" {
+				buf := bytes.NewBufferString(test.Body)
+				inBody = buf
+			}
+			req, err := http.NewRequest(method, "http://1.2.3.4/"+test.URL, inBody)
+			require.NoError(t, err)
+			if test.Range != "" {
+				req.Header.Add("Range", test.Range)
+			}
+			if test.ContentType != "" {
+				req.Header.Add("Content-Type", test.ContentType)
+			}
+
+			w := httptest.NewRecorder()
+			rcServer.handler(w, req)
+			resp := w.Result()
+
+			assert.Equal(t, test.Status, resp.StatusCode)
+			body, err := ioutil.ReadAll(resp.Body)
+			require.NoError(t, err)
+
+			if test.Contains == nil {
+				assert.Equal(t, test.Expected, string(body))
+			} else {
+				assert.True(t, test.Contains.Match(body), fmt.Sprintf("body didn't match: %v: %v", test.Contains, string(body)))
+			}
+
+			for k, v := range test.Headers {
+				assert.Equal(t, v, resp.Header.Get(k), k)
+			}
+		})
+	}
+}
+
+// return an enabled rc
+func newTestOpt() rc.Options {
+	opt := rc.DefaultOpt
+	opt.Enabled = true
+	return opt
+}
+
+func TestFileServing(t *testing.T) {
+	tests := []testRun{{
+		Name:   "index",
+		URL:    "",
+		Status: http.StatusOK,
+		Expected: `<pre>
+<a href="dir/">dir/</a>
+<a href="file.txt">file.txt</a>
+</pre>
+`,
+	}, {
+		Name:     "notfound",
+		URL:      "notfound",
+		Status:   http.StatusNotFound,
+		Expected: "404 page not found\n",
+	}, {
+		Name:     "dirnotfound",
+		URL:      "dirnotfound/",
+		Status:   http.StatusNotFound,
+		Expected: "404 page not found\n",
+	}, {
+		Name:   "dir",
+		URL:    "dir/",
+		Status: http.StatusOK,
+		Expected: `<pre>
+<a href="file2.txt">file2.txt</a>
+</pre>
+`,
+	}, {
+		Name:     "file",
+		URL:      "file.txt",
+		Status:   http.StatusOK,
+		Expected: "this is file1.txt\n",
+		Headers: map[string]string{
+			"Content-Length": "18",
+		},
+	}, {
+		Name:     "file2",
+		URL:      "dir/file2.txt",
+		Status:   http.StatusOK,
+		Expected: "this is dir/file2.txt\n",
+	}, {
+		Name:     "file-head",
+		URL:      "file.txt",
+		Method:   "HEAD",
+		Status:   http.StatusOK,
+		Expected: ``,
+		Headers: map[string]string{
+			"Content-Length": "18",
+		},
+	}, {
+		Name:     "file-range",
+		URL:      "file.txt",
+		Status:   http.StatusPartialContent,
+		Range:    "bytes=8-12",
+		Expected: `file1`,
+	}}
+	opt := newTestOpt()
+	opt.Serve = true
+	opt.Files = testFs
+	testServer(t, tests, &opt)
+}
+
+func TestRemoteServing(t *testing.T) {
+	tests := []testRun{
+		// Test serving files from the test remote
+		{
+			Name:   "index",
+			URL:    remoteURL + "",
+			Status: http.StatusOK,
+			Expected: `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Directory listing of /</title>
+</head>
+<body>
+<h1>Directory listing of /</h1>
+<a href="dir/">dir/</a><br />
+<a href="file.txt">file.txt</a><br />
+</body>
+</html>
+`,
+		}, {
+			Name:   "notfound-index",
+			URL:    "[notfound]/",
+			Status: http.StatusNotFound,
+			Expected: `{
+	"error": "failed to list directory: directory not found",
+	"input": null,
+	"path": "",
+	"status": 404
+}
+`,
+		}, {
+			Name:   "notfound",
+			URL:    remoteURL + "notfound",
+			Status: http.StatusNotFound,
+			Expected: `{
+	"error": "failed to find object: object not found",
+	"input": null,
+	"path": "/notfound",
+	"status": 404
+}
+`,
+		}, {
+			Name:   "dirnotfound",
+			URL:    remoteURL + "dirnotfound/",
+			Status: http.StatusNotFound,
+			Expected: `{
+	"error": "failed to list directory: directory not found",
+	"input": null,
+	"path": "dirnotfound",
+	"status": 404
+}
+`,
+		}, {
+			Name:   "dir",
+			URL:    remoteURL + "dir/",
+			Status: http.StatusOK,
+			Expected: `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Directory listing of /dir</title>
+</head>
+<body>
+<h1>Directory listing of /dir</h1>
+<a href="file2.txt">file2.txt</a><br />
+</body>
+</html>
+`,
+		}, {
+			Name:     "file",
+			URL:      remoteURL + "file.txt",
+			Status:   http.StatusOK,
+			Expected: "this is file1.txt\n",
+			Headers: map[string]string{
+				"Content-Length": "18",
+			},
+		}, {
+			Name:     "file2",
+			URL:      remoteURL + "dir/file2.txt",
+			Status:   http.StatusOK,
+			Expected: "this is dir/file2.txt\n",
+		}, {
+			Name:     "file-head",
+			URL:      remoteURL + "file.txt",
+			Method:   "HEAD",
+			Status:   http.StatusOK,
+			Expected: ``,
+			Headers: map[string]string{
+				"Content-Length": "18",
+			},
+		}, {
+			Name:     "file-range",
+			URL:      remoteURL + "file.txt",
+			Status:   http.StatusPartialContent,
+			Range:    "bytes=8-12",
+			Expected: `file1`,
+		}, {
+			Name:   "bad-remote",
+			URL:    "[notfoundremote:]/",
+			Status: http.StatusInternalServerError,
+			Expected: `{
+	"error": "failed to make Fs: didn't find section in config file",
+	"input": null,
+	"path": "/",
+	"status": 500
+}
+`,
+		}}
+	opt := newTestOpt()
+	opt.Serve = true
+	opt.Files = testFs
+	testServer(t, tests, &opt)
+}
+
+func TestRC(t *testing.T) {
+	tests := []testRun{{
+		Name:   "rc-root",
+		URL:    "",
+		Method: "POST",
+		Status: http.StatusNotFound,
+		Expected: `{
+	"error": "couldn't find method \"\"",
+	"input": {},
+	"path": "",
+	"status": 404
+}
+`,
+	}, {
+		Name:     "rc-noop",
+		URL:      "rc/noop",
+		Method:   "POST",
+		Status:   http.StatusOK,
+		Expected: "{}\n",
+	}, {
+		Name:   "rc-error",
+		URL:    "rc/error",
+		Method: "POST",
+		Status: http.StatusInternalServerError,
+		Expected: `{
+	"error": "arbitrary error on input map[]",
+	"input": {},
+	"path": "rc/error",
+	"status": 500
+}
+`,
+	}, {
+		Name:     "core-gc",
+		URL:      "core/gc", // returns nil, nil so check it is made into {}
+		Method:   "POST",
+		Status:   http.StatusOK,
+		Expected: "{}\n",
+	}, {
+		Name:   "url-params",
+		URL:    "rc/noop?param1=potato&param2=sausage",
+		Method: "POST",
+		Status: http.StatusOK,
+		Expected: `{
+	"param1": "potato",
+	"param2": "sausage"
+}
+`,
+	}, {
+		Name:        "json",
+		URL:         "rc/noop",
+		Method:      "POST",
+		Body:        `{ "param1":"string", "param2":true }`,
+		ContentType: "application/json",
+		Status:      http.StatusOK,
+		Expected: `{
+	"param1": "string",
+	"param2": true
+}
+`,
+	}, {
+		Name:        "json-and-url-params",
+		URL:         "rc/noop?param1=potato&param2=sausage",
+		Method:      "POST",
+		Body:        `{ "param1":"string", "param3":true }`,
+		ContentType: "application/json",
+		Status:      http.StatusOK,
+		Expected: `{
+	"param1": "string",
+	"param2": "sausage",
+	"param3": true
+}
+`,
+	}, {
+		Name:        "json-bad",
+		URL:         "rc/noop?param1=potato&param2=sausage",
+		Method:      "POST",
+		Body:        `{ param1":"string", "param3":true }`,
+		ContentType: "application/json",
+		Status:      http.StatusBadRequest,
+		Expected: `{
+	"error": "failed to read input JSON: invalid character 'p' looking for beginning of object key string",
+	"input": {
+		"param1": "potato",
+		"param2": "sausage"
+	},
+	"path": "rc/noop",
+	"status": 400
+}
+`,
+	}, {
+		Name:        "form",
+		URL:         "rc/noop",
+		Method:      "POST",
+		Body:        `param1=string&param2=true`,
+		ContentType: "application/x-www-form-urlencoded",
+		Status:      http.StatusOK,
+		Expected: `{
+	"param1": "string",
+	"param2": "true"
+}
+`,
+	}, {
+		Name:        "form-and-url-params",
+		URL:         "rc/noop?param1=potato&param2=sausage",
+		Method:      "POST",
+		Body:        `param1=string&param3=true`,
+		ContentType: "application/x-www-form-urlencoded",
+		Status:      http.StatusOK,
+		Expected: `{
+	"param1": "potato",
+	"param2": "sausage",
+	"param3": "true"
+}
+`,
+	}, {
+		Name:        "form-bad",
+		URL:         "rc/noop?param1=potato&param2=sausage",
+		Method:      "POST",
+		Body:        `%zz`,
+		ContentType: "application/x-www-form-urlencoded",
+		Status:      http.StatusBadRequest,
+		Expected: `{
+	"error": "failed to parse form/URL parameters: invalid URL escape \"%zz\"",
+	"input": null,
+	"path": "rc/noop",
+	"status": 400
+}
+`,
+	}}
+	opt := newTestOpt()
+	opt.Serve = true
+	opt.Files = testFs
+	testServer(t, tests, &opt)
+}
+
+func TestMethods(t *testing.T) {
+	tests := []testRun{{
+		Name:     "options",
+		URL:      "",
+		Method:   "OPTIONS",
+		Status:   http.StatusOK,
+		Expected: "",
+		Headers: map[string]string{
+			"Access-Control-Allow-Origin":  "*",
+			"Access-Control-Allow-Headers": "",
+		},
+	}, {
+		Name:   "bad",
+		URL:    "",
+		Method: "POTATO",
+		Status: http.StatusMethodNotAllowed,
+		Expected: `{
+	"error": "method \"POTATO\" not allowed",
+	"input": null,
+	"path": "",
+	"status": 405
+}
+`,
+	}}
+	opt := newTestOpt()
+	opt.Serve = true
+	opt.Files = testFs
+	testServer(t, tests, &opt)
+}
+
+var matchRemoteDirListing = regexp.MustCompile(`<title>List of all rclone remotes.</title>`)
+
+func TestServingRoot(t *testing.T) {
+	tests := []testRun{{
+		Name:     "rootlist",
+		URL:      "*",
+		Status:   http.StatusOK,
+		Contains: matchRemoteDirListing,
+	}}
+	opt := newTestOpt()
+	opt.Serve = true
+	opt.Files = testFs
+	testServer(t, tests, &opt)
+}
+
+func TestServingRootNoFiles(t *testing.T) {
+	tests := []testRun{{
+		Name:     "rootlist",
+		URL:      "",
+		Status:   http.StatusOK,
+		Contains: matchRemoteDirListing,
+	}}
+	opt := newTestOpt()
+	opt.Serve = true
+	opt.Files = ""
+	testServer(t, tests, &opt)
+}
+
+func TestNoFiles(t *testing.T) {
+	tests := []testRun{{
+		Name:     "file",
+		URL:      "file.txt",
+		Status:   http.StatusNotFound,
+		Expected: "Not Found\n",
+	}, {
+		Name:     "dir",
+		URL:      "dir/",
+		Status:   http.StatusNotFound,
+		Expected: "Not Found\n",
+	}}
+	opt := newTestOpt()
+	opt.Serve = true
+	opt.Files = ""
+	testServer(t, tests, &opt)
+}
+
+func TestNoServe(t *testing.T) {
+	tests := []testRun{{
+		Name:     "file",
+		URL:      remoteURL + "file.txt",
+		Status:   http.StatusNotFound,
+		Expected: "404 page not found\n",
+	}, {
+		Name:     "dir",
+		URL:      remoteURL + "dir/",
+		Status:   http.StatusNotFound,
+		Expected: "404 page not found\n",
+	}}
+	opt := newTestOpt()
+	opt.Serve = false
+	opt.Files = testFs
+	testServer(t, tests, &opt)
+}
+
+func TestRCAsync(t *testing.T) {
+	tests := []testRun{{
+		Name:        "ok",
+		URL:         "rc/noop",
+		Method:      "POST",
+		ContentType: "application/json",
+		Body:        `{ "_async":true }`,
+		Status:      http.StatusOK,
+		Expected: `{
+	"jobid": 1
+}
+`,
+	}, {
+		Name:        "bad",
+		URL:         "rc/noop",
+		Method:      "POST",
+		ContentType: "application/json",
+		Body:        `{ "_async":"truthy" }`,
+		Status:      http.StatusBadRequest,
+		Expected: `{
+	"error": "couldn't parse key \"_async\" (truthy) as bool: strconv.ParseBool: parsing \"truthy\": invalid syntax",
+	"input": {
+		"_async": "truthy"
+	},
+	"path": "rc/noop",
+	"status": 400
+}
+`,
+	}}
+	opt := newTestOpt()
+	opt.Serve = true
+	opt.Files = ""
+	testServer(t, tests, &opt)
+}
diff --git a/fs/rc/rcserver/testdata/files/dir/file2.txt b/fs/rc/rcserver/testdata/files/dir/file2.txt
new file mode 100644
index 000000000..9e9f7593b
--- /dev/null
+++ b/fs/rc/rcserver/testdata/files/dir/file2.txt
@@ -0,0 +1 @@
+this is dir/file2.txt
diff --git a/fs/rc/rcserver/testdata/files/file.txt b/fs/rc/rcserver/testdata/files/file.txt
new file mode 100644
index 000000000..aedf83c41
--- /dev/null
+++ b/fs/rc/rcserver/testdata/files/file.txt
@@ -0,0 +1 @@
+this is file1.txt