rcserver: serve directories as well as files

This commit is contained in:
Nick Craig-Wood 2018-10-28 14:31:24 +00:00
parent 370c218c63
commit 89550e7121
9 changed files with 722 additions and 45 deletions

View File

@ -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 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 for GET requests on the URL passed in. It will also open the URL in
the browser when rclone is run. 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) { Run: func(command *cobra.Command, args []string) {
cmd.CheckArgs(0, 1, command, args) cmd.CheckArgs(0, 1, command, args)

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"path"
"strconv" "strconv"
"github.com/ncw/rclone/fs" "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)) 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" { if r.Method == "HEAD" {
return return
} }

View File

@ -9,46 +9,78 @@ date: "2018-03-05"
If rclone is run with the `--rc` flag then it starts an http server If rclone is run with the `--rc` flag then it starts an http server
which can be used to remote control rclone. 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! **NB** this is experimental and everything here is subject to change!
## Supported parameters ## Supported parameters
#### --rc #### ### --rc
Flag to start the http server listen on remote requests 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") 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) SSL PEM key (concatenation of certificate and CA certificate)
#### --rc-client-ca=PATH #### ### --rc-client-ca=PATH
Client certificate authority to verify clients with Client certificate authority to verify clients with
#### --rc-htpasswd=PATH #### ### --rc-htpasswd=PATH
htpasswd file - if not provided no authentication is done htpasswd file - if not provided no authentication is done
#### --rc-key=PATH #### ### --rc-key=PATH
SSL PEM Private key SSL PEM Private key
#### --rc-max-header-bytes=VALUE #### ### --rc-max-header-bytes=VALUE
Maximum size of request header (default 4096) Maximum size of request header (default 4096)
#### --rc-user=VALUE #### ### --rc-user=VALUE
User name for authentication. User name for authentication.
#### --rc-pass=VALUE #### ### --rc-pass=VALUE
Password for authentication. Password for authentication.
#### --rc-realm=VALUE #### ### --rc-realm=VALUE
Realm for authentication (default "rclone") Realm for authentication (default "rclone")
#### --rc-server-read-timeout=DURATION #### ### --rc-server-read-timeout=DURATION
Timeout for server reading data (default 1h0m0s) Timeout for server reading data (default 1h0m0s)
#### --rc-server-write-timeout=DURATION #### ### --rc-server-write-timeout=DURATION
Timeout for server writing data (default 1h0m0s) 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 ## Accessing the remote control via the rclone rc command
Rclone itself implements the remote control protocol in its `rclone 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 ### 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 Response
@ -409,7 +441,7 @@ Response
Here is what an error response looks like: 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 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 curl: (22) The requested URL returned error: 400 Bad Request
$ echo $? $ echo $?
22 22
@ -434,7 +466,7 @@ $ echo $?
### Using POST with a form ### 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 Response
@ -450,7 +482,7 @@ Note that you can combine these with URL parameters too with the POST
parameters taking precedence. 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 Response
@ -467,7 +499,7 @@ Response
### Using POST with a JSON blob ### 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 response
@ -483,7 +515,7 @@ This can be combined with URL parameters too if required. The JSON
blob takes precedence. 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'
``` ```
``` ```

View File

@ -19,7 +19,8 @@ import (
type Options struct { type Options struct {
HTTPOptions httplib.Options HTTPOptions httplib.Options
Enabled bool // set to enable the server 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 // DefaultOpt is the default values used for Options

View File

@ -17,6 +17,7 @@ var (
func AddFlags(flagSet *pflag.FlagSet) { func AddFlags(flagSet *pflag.FlagSet) {
rc.AddOption("rc", &Opt) rc.AddOption("rc", &Opt)
flags.BoolVarP(flagSet, &Opt.Enabled, "rc", "", false, "Enable the remote control server.") 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) httpflags.AddFlagsPrefix(flagSet, "rc-", &Opt.HTTPOptions)
} }

View File

@ -5,11 +5,16 @@ import (
"encoding/json" "encoding/json"
"mime" "mime"
"net/http" "net/http"
"net/url"
"regexp"
"sort"
"strings" "strings"
"github.com/ncw/rclone/cmd/serve/httplib" "github.com/ncw/rclone/cmd/serve/httplib"
"github.com/ncw/rclone/cmd/serve/httplib/serve" "github.com/ncw/rclone/cmd/serve/httplib/serve"
"github.com/ncw/rclone/fs" "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/ncw/rclone/fs/rc"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/skratchdot/open-golang/open" "github.com/skratchdot/open-golang/open"
@ -18,7 +23,8 @@ import (
// Start the remote control server if configured // Start the remote control server if configured
func Start(opt *rc.Options) { func Start(opt *rc.Options) {
if opt.Enabled { if opt.Enabled {
s := newServer(opt) // Serve on the DefaultServeMux so can have global registrations appear
s := newServer(opt, http.DefaultServeMux)
go s.serve() go s.serve()
} }
} }
@ -27,13 +33,13 @@ func Start(opt *rc.Options) {
type server struct { type server struct {
srv *httplib.Server srv *httplib.Server
files http.Handler files http.Handler
opt *rc.Options
} }
func newServer(opt *rc.Options) *server { func newServer(opt *rc.Options, mux *http.ServeMux) *server {
// Serve on the DefaultServeMux so can have global registrations appear
mux := http.DefaultServeMux
s := &server{ s := &server{
srv: httplib.NewServer(mux, &opt.HTTPOptions), srv: httplib.NewServer(mux, &opt.HTTPOptions),
opt: opt,
} }
mux.HandleFunc("/", s.handler) 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 // handler reads incoming requests and dispatches them
func (s *server) handler(w http.ResponseWriter, r *http.Request) { 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", "*") 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) s.handlePost(w, r, path)
case "OPTIONS": case "OPTIONS":
s.handleOptions(w, r, path) s.handleOptions(w, r, path)
case "GET": case "GET", "HEAD":
s.handleGet(w, r, path) s.handleGet(w, r, path)
default: default:
writeError(path, nil, w, errors.Errorf("method %q not allowed", r.Method), http.StatusMethodNotAllowed) 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) { 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 contentType := r.Header.Get("Content-Type")
err := r.ParseForm()
if err != nil { values := r.URL.Query()
writeError(path, nil, w, errors.Wrap(err, "failed to parse form/URL parameters"), http.StatusBadRequest) if contentType == "application/x-www-form-urlencoded" {
return // 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 // Read the POST and URL parameters into in
in := make(rc.Params) in := make(rc.Params)
for k, vs := range r.Form { for k, vs := range values {
if len(vs) > 0 { if len(vs) > 0 {
in[k] = vs[len(vs)-1] in[k] = vs[len(vs)-1]
} }
} }
// Parse a JSON blob from the input // 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) err := json.NewDecoder(r.Body).Decode(&in)
if err != nil { if err != nil {
writeError(path, in, w, errors.Wrap(err, "failed to read input JSON"), http.StatusBadRequest) 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 // Find the call
call := rc.Calls.Get(path) call := rc.Calls.Get(path)
if call == nil { 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 return
} }
@ -176,24 +188,72 @@ func (s *server) handleOptions(w http.ResponseWriter, r *http.Request, path stri
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
func (s *server) handleGet(w http.ResponseWriter, r *http.Request, path string) { func (s *server) serveRoot(w http.ResponseWriter, r *http.Request) {
// if we have an &fs parameter we are serving from a different fs remotes := config.FileSections()
fsName := r.URL.Query().Get("fs") sort.Strings(remotes)
if fsName != "" { directory := serve.NewDirectory("")
f, err := rc.GetCachedFs(fsName) 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 { 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 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) o, err := f.NewObject(path)
if err != nil { if err != nil {
writeError(path, nil, w, errors.Wrap(err, "failed to find object"), http.StatusInternalServerError) writeError(path, nil, w, errors.Wrap(err, "failed to find object"), http.StatusInternalServerError)
return return
} }
serve.Object(w, r, o) 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)
}

View File

@ -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)
}

View File

@ -0,0 +1 @@
this is dir/file2.txt

View File

@ -0,0 +1 @@
this is file1.txt