overdue
This commit is contained in:
259
.rclone_repo/cmd/serve/http/http.go
Executable file
259
.rclone_repo/cmd/serve/http/http.go
Executable file
@@ -0,0 +1,259 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ncw/rclone/cmd"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib/httpflags"
|
||||
"github.com/ncw/rclone/fs"
|
||||
"github.com/ncw/rclone/fs/accounting"
|
||||
"github.com/ncw/rclone/lib/rest"
|
||||
"github.com/ncw/rclone/vfs"
|
||||
"github.com/ncw/rclone/vfs/vfsflags"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpflags.AddFlags(Command.Flags())
|
||||
vfsflags.AddFlags(Command.Flags())
|
||||
}
|
||||
|
||||
// Command definition for cobra
|
||||
var Command = &cobra.Command{
|
||||
Use: "http remote:path",
|
||||
Short: `Serve the remote over HTTP.`,
|
||||
Long: `rclone serve http implements a basic web server to serve the remote
|
||||
over HTTP. This can be viewed in a web browser or you can make a
|
||||
remote of type http read from it.
|
||||
|
||||
You can use the filter flags (eg --include, --exclude) to control what
|
||||
is served.
|
||||
|
||||
The server will log errors. Use -v to see access logs.
|
||||
|
||||
--bwlimit will be respected for file transfers. Use --stats to
|
||||
control the stats printing.
|
||||
` + httplib.Help + vfs.Help,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
cmd.CheckArgs(1, 1, command, args)
|
||||
f := cmd.NewFsSrc(args)
|
||||
cmd.Run(false, true, command, func() error {
|
||||
s := newServer(f, &httpflags.Opt)
|
||||
s.serve()
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// server contains everything to run the server
|
||||
type server struct {
|
||||
f fs.Fs
|
||||
vfs *vfs.VFS
|
||||
srv *httplib.Server
|
||||
}
|
||||
|
||||
func newServer(f fs.Fs, opt *httplib.Options) *server {
|
||||
mux := http.NewServeMux()
|
||||
s := &server{
|
||||
f: f,
|
||||
vfs: vfs.New(f, &vfsflags.Opt),
|
||||
srv: httplib.NewServer(mux, opt),
|
||||
}
|
||||
mux.HandleFunc("/", s.handler)
|
||||
return s
|
||||
}
|
||||
|
||||
// serve runs the http server - doesn't return
|
||||
func (s *server) serve() {
|
||||
err := s.srv.Serve()
|
||||
if err != nil {
|
||||
fs.Errorf(s.f, "Opening listener: %v", err)
|
||||
}
|
||||
fs.Logf(s.f, "Serving on %s", s.srv.URL())
|
||||
s.srv.Wait()
|
||||
}
|
||||
|
||||
// handler reads incoming requests and dispatches them
|
||||
func (s *server) handler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" && r.Method != "HEAD" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Server", "rclone/"+fs.Version)
|
||||
|
||||
urlPath := r.URL.Path
|
||||
isDir := strings.HasSuffix(urlPath, "/")
|
||||
remote := strings.Trim(urlPath, "/")
|
||||
if isDir {
|
||||
s.serveDir(w, r, remote)
|
||||
} else {
|
||||
s.serveFile(w, r, remote)
|
||||
}
|
||||
}
|
||||
|
||||
// entry is a directory entry
|
||||
type entry struct {
|
||||
remote string
|
||||
URL string
|
||||
Leaf string
|
||||
}
|
||||
|
||||
// entries represents a directory
|
||||
type entries []entry
|
||||
|
||||
// addEntry adds an entry to that directory
|
||||
func (es *entries) addEntry(node interface {
|
||||
Path() string
|
||||
Name() string
|
||||
IsDir() bool
|
||||
}) {
|
||||
remote := node.Path()
|
||||
leaf := node.Name()
|
||||
urlRemote := leaf
|
||||
if node.IsDir() {
|
||||
leaf += "/"
|
||||
urlRemote += "/"
|
||||
}
|
||||
*es = append(*es, entry{remote: remote, URL: rest.URLPathEscape(urlRemote), Leaf: leaf})
|
||||
}
|
||||
|
||||
// indexPage is a directory listing template
|
||||
var indexPage = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ .Title }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ range $i := .Entries }}<a href="{{ $i.URL }}">{{ $i.Leaf }}</a><br />
|
||||
{{ end }}</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// indexTemplate is the instantiated indexPage
|
||||
var indexTemplate = template.Must(template.New("index").Parse(indexPage))
|
||||
|
||||
// indexData is used to fill in the indexTemplate
|
||||
type indexData struct {
|
||||
Title string
|
||||
Entries entries
|
||||
}
|
||||
|
||||
// error returns an http.StatusInternalServerError and logs the error
|
||||
func internalError(what interface{}, w http.ResponseWriter, text string, err error) {
|
||||
fs.CountError(err)
|
||||
fs.Errorf(what, "%s: %v", text, err)
|
||||
http.Error(w, text+".", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// serveDir serves a directory index at dirRemote
|
||||
func (s *server) serveDir(w http.ResponseWriter, r *http.Request, dirRemote string) {
|
||||
// List the directory
|
||||
node, err := s.vfs.Stat(dirRemote)
|
||||
if err == vfs.ENOENT {
|
||||
http.Error(w, "Directory not found", http.StatusNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
internalError(dirRemote, w, "Failed to list directory", err)
|
||||
return
|
||||
}
|
||||
if !node.IsDir() {
|
||||
http.Error(w, "Not a directory", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
dir := node.(*vfs.Dir)
|
||||
dirEntries, err := dir.ReadDirAll()
|
||||
if err != nil {
|
||||
internalError(dirRemote, w, "Failed to list directory", err)
|
||||
return
|
||||
}
|
||||
|
||||
var out entries
|
||||
for _, node := range dirEntries {
|
||||
out.addEntry(node)
|
||||
}
|
||||
|
||||
// Account the transfer
|
||||
accounting.Stats.Transferring(dirRemote)
|
||||
defer accounting.Stats.DoneTransferring(dirRemote, true)
|
||||
|
||||
fs.Infof(dirRemote, "%s: Serving directory", r.RemoteAddr)
|
||||
err = indexTemplate.Execute(w, indexData{
|
||||
Entries: out,
|
||||
Title: fmt.Sprintf("Directory listing of /%s", dirRemote),
|
||||
})
|
||||
if err != nil {
|
||||
internalError(dirRemote, w, "Failed to render template", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// serveFile serves a file object at remote
|
||||
func (s *server) serveFile(w http.ResponseWriter, r *http.Request, remote string) {
|
||||
node, err := s.vfs.Stat(remote)
|
||||
if err == vfs.ENOENT {
|
||||
fs.Infof(remote, "%s: File not found", r.RemoteAddr)
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
internalError(remote, w, "Failed to find file", err)
|
||||
return
|
||||
}
|
||||
if !node.IsFile() {
|
||||
http.Error(w, "Not a file", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
entry := node.DirEntry()
|
||||
if entry == nil {
|
||||
http.Error(w, "Can't open file being written", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
obj := entry.(fs.Object)
|
||||
file := node.(*vfs.File)
|
||||
|
||||
// Set content length since we know how long the object is
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(node.Size(), 10))
|
||||
|
||||
// Set content type
|
||||
mimeType := fs.MimeType(obj)
|
||||
if mimeType == "application/octet-stream" && path.Ext(remote) == "" {
|
||||
// Leave header blank so http server guesses
|
||||
} else {
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
}
|
||||
|
||||
// If HEAD no need to read the object since we have set the headers
|
||||
if r.Method == "HEAD" {
|
||||
return
|
||||
}
|
||||
|
||||
// open the object
|
||||
in, err := file.Open(os.O_RDONLY)
|
||||
if err != nil {
|
||||
internalError(remote, w, "Failed to open file", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err := in.Close()
|
||||
if err != nil {
|
||||
fs.Errorf(remote, "Failed to close file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Account the transfer
|
||||
accounting.Stats.Transferring(remote)
|
||||
defer accounting.Stats.DoneTransferring(remote, true)
|
||||
// FIXME in = fs.NewAccount(in, obj).WithBuffer() // account the transfer
|
||||
|
||||
// Serve the file
|
||||
http.ServeContent(w, r, remote, node.ModTime(), in)
|
||||
}
|
||||
237
.rclone_repo/cmd/serve/http/http_test.go
Executable file
237
.rclone_repo/cmd/serve/http/http_test.go
Executable file
@@ -0,0 +1,237 @@
|
||||
// +build go1.8
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/ncw/rclone/backend/local"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib"
|
||||
"github.com/ncw/rclone/fs"
|
||||
"github.com/ncw/rclone/fs/config"
|
||||
"github.com/ncw/rclone/fs/filter"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
updateGolden = flag.Bool("updategolden", false, "update golden files for regression test")
|
||||
httpServer *server
|
||||
)
|
||||
|
||||
const (
|
||||
testBindAddress = "localhost:51777"
|
||||
testURL = "http://" + testBindAddress + "/"
|
||||
)
|
||||
|
||||
func startServer(t *testing.T, f fs.Fs) {
|
||||
opt := httplib.DefaultOpt
|
||||
opt.ListenAddr = testBindAddress
|
||||
httpServer = newServer(f, &opt)
|
||||
go httpServer.serve()
|
||||
|
||||
// try to connect to the test server
|
||||
pause := time.Millisecond
|
||||
for i := 0; i < 10; i++ {
|
||||
conn, err := net.Dial("tcp", testBindAddress)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
// t.Logf("couldn't connect, sleeping for %v: %v", pause, err)
|
||||
time.Sleep(pause)
|
||||
pause *= 2
|
||||
}
|
||||
t.Fatal("couldn't connect to server")
|
||||
|
||||
}
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
// Configure the remote
|
||||
config.LoadConfig()
|
||||
// fs.Config.LogLevel = fs.LogLevelDebug
|
||||
// fs.Config.DumpHeaders = true
|
||||
// fs.Config.DumpBodies = true
|
||||
|
||||
// exclude files called hidden.txt and directories called hidden
|
||||
require.NoError(t, filter.Active.AddRule("- hidden.txt"))
|
||||
require.NoError(t, filter.Active.AddRule("- hidden/**"))
|
||||
|
||||
// Create a test Fs
|
||||
f, err := fs.NewFs("testdata/files")
|
||||
require.NoError(t, err)
|
||||
|
||||
startServer(t, f)
|
||||
}
|
||||
|
||||
// check body against the file, or re-write body if -updategolden is
|
||||
// set.
|
||||
func checkGolden(t *testing.T, fileName string, got []byte) {
|
||||
if *updateGolden {
|
||||
t.Logf("Updating golden file %q", fileName)
|
||||
err := ioutil.WriteFile(fileName, got, 0666)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
want, err := ioutil.ReadFile(fileName)
|
||||
require.NoError(t, err)
|
||||
wants := strings.Split(string(want), "\n")
|
||||
gots := strings.Split(string(got), "\n")
|
||||
assert.Equal(t, wants, gots, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGET(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
URL string
|
||||
Status int
|
||||
Golden string
|
||||
Method string
|
||||
Range string
|
||||
}{
|
||||
{
|
||||
URL: "",
|
||||
Status: http.StatusOK,
|
||||
Golden: "testdata/golden/index.html",
|
||||
},
|
||||
{
|
||||
URL: "notfound",
|
||||
Status: http.StatusNotFound,
|
||||
Golden: "testdata/golden/notfound.html",
|
||||
},
|
||||
{
|
||||
URL: "dirnotfound/",
|
||||
Status: http.StatusNotFound,
|
||||
Golden: "testdata/golden/dirnotfound.html",
|
||||
},
|
||||
{
|
||||
URL: "hidden/",
|
||||
Status: http.StatusNotFound,
|
||||
Golden: "testdata/golden/hiddendir.html",
|
||||
},
|
||||
{
|
||||
URL: "one%25.txt",
|
||||
Status: http.StatusOK,
|
||||
Golden: "testdata/golden/one.txt",
|
||||
},
|
||||
{
|
||||
URL: "hidden.txt",
|
||||
Status: http.StatusNotFound,
|
||||
Golden: "testdata/golden/hidden.txt",
|
||||
},
|
||||
{
|
||||
URL: "three/",
|
||||
Status: http.StatusOK,
|
||||
Golden: "testdata/golden/three.html",
|
||||
},
|
||||
{
|
||||
URL: "three/a.txt",
|
||||
Status: http.StatusOK,
|
||||
Golden: "testdata/golden/a.txt",
|
||||
},
|
||||
{
|
||||
URL: "",
|
||||
Method: "HEAD",
|
||||
Status: http.StatusOK,
|
||||
Golden: "testdata/golden/indexhead.txt",
|
||||
},
|
||||
{
|
||||
URL: "one%25.txt",
|
||||
Method: "HEAD",
|
||||
Status: http.StatusOK,
|
||||
Golden: "testdata/golden/onehead.txt",
|
||||
},
|
||||
{
|
||||
URL: "",
|
||||
Method: "POST",
|
||||
Status: http.StatusMethodNotAllowed,
|
||||
Golden: "testdata/golden/indexpost.txt",
|
||||
},
|
||||
{
|
||||
URL: "one%25.txt",
|
||||
Method: "POST",
|
||||
Status: http.StatusMethodNotAllowed,
|
||||
Golden: "testdata/golden/onepost.txt",
|
||||
},
|
||||
{
|
||||
URL: "two.txt",
|
||||
Status: http.StatusOK,
|
||||
Golden: "testdata/golden/two.txt",
|
||||
},
|
||||
{
|
||||
URL: "two.txt",
|
||||
Status: http.StatusPartialContent,
|
||||
Range: "bytes=2-5",
|
||||
Golden: "testdata/golden/two2-5.txt",
|
||||
},
|
||||
{
|
||||
URL: "two.txt",
|
||||
Status: http.StatusPartialContent,
|
||||
Range: "bytes=0-6",
|
||||
Golden: "testdata/golden/two-6.txt",
|
||||
},
|
||||
{
|
||||
URL: "two.txt",
|
||||
Status: http.StatusPartialContent,
|
||||
Range: "bytes=3-",
|
||||
Golden: "testdata/golden/two3-.txt",
|
||||
},
|
||||
} {
|
||||
method := test.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
req, err := http.NewRequest(method, testURL+test.URL, nil)
|
||||
require.NoError(t, err)
|
||||
if test.Range != "" {
|
||||
req.Header.Add("Range", test.Range)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.Status, resp.StatusCode, test.Golden)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
checkGolden(t, test.Golden, body)
|
||||
}
|
||||
}
|
||||
|
||||
type mockNode struct {
|
||||
path string
|
||||
isdir bool
|
||||
}
|
||||
|
||||
func (n mockNode) Path() string { return n.path }
|
||||
func (n mockNode) Name() string {
|
||||
if n.path == "" {
|
||||
return ""
|
||||
}
|
||||
return path.Base(n.path)
|
||||
}
|
||||
func (n mockNode) IsDir() bool { return n.isdir }
|
||||
|
||||
func TestAddEntry(t *testing.T) {
|
||||
var es entries
|
||||
es.addEntry(mockNode{path: "", isdir: true})
|
||||
es.addEntry(mockNode{path: "dir", isdir: true})
|
||||
es.addEntry(mockNode{path: "a/b/c/d.txt", isdir: false})
|
||||
es.addEntry(mockNode{path: "a/b/c/colon:colon.txt", isdir: false})
|
||||
es.addEntry(mockNode{path: "\"quotes\".txt", isdir: false})
|
||||
assert.Equal(t, entries{
|
||||
{remote: "", URL: "/", Leaf: "/"},
|
||||
{remote: "dir", URL: "dir/", Leaf: "dir/"},
|
||||
{remote: "a/b/c/d.txt", URL: "d.txt", Leaf: "d.txt"},
|
||||
{remote: "a/b/c/colon:colon.txt", URL: "./colon:colon.txt", Leaf: "colon:colon.txt"},
|
||||
{remote: "\"quotes\".txt", URL: "%22quotes%22.txt", Leaf: "\"quotes\".txt"},
|
||||
}, es)
|
||||
}
|
||||
|
||||
func TestFinalise(t *testing.T) {
|
||||
httpServer.srv.Close()
|
||||
}
|
||||
1
.rclone_repo/cmd/serve/http/testdata/files/hidden.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/files/hidden.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
hidden
|
||||
1
.rclone_repo/cmd/serve/http/testdata/files/hidden/file.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/files/hidden/file.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
hiddenfile
|
||||
1
.rclone_repo/cmd/serve/http/testdata/files/one%.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/files/one%.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
one%
|
||||
1
.rclone_repo/cmd/serve/http/testdata/files/three/a.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/files/three/a.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
three
|
||||
1
.rclone_repo/cmd/serve/http/testdata/files/three/b.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/files/three/b.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
threeb
|
||||
1
.rclone_repo/cmd/serve/http/testdata/files/two.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/files/two.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
0123456789
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/a.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/a.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
three
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/dirnotfound.html
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/dirnotfound.html
vendored
Executable file
@@ -0,0 +1 @@
|
||||
Directory not found
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/hidden.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/hidden.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
File not found
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/hiddendir.html
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/hiddendir.html
vendored
Executable file
@@ -0,0 +1 @@
|
||||
Directory not found
|
||||
13
.rclone_repo/cmd/serve/http/testdata/golden/index.html
vendored
Executable file
13
.rclone_repo/cmd/serve/http/testdata/golden/index.html
vendored
Executable file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Directory listing of /</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Directory listing of /</h1>
|
||||
<a href="one%25.txt">one%.txt</a><br />
|
||||
<a href="three/">three/</a><br />
|
||||
<a href="two.txt">two.txt</a><br />
|
||||
</body>
|
||||
</html>
|
||||
0
.rclone_repo/cmd/serve/http/testdata/golden/indexhead.txt
vendored
Executable file
0
.rclone_repo/cmd/serve/http/testdata/golden/indexhead.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/indexpost.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/indexpost.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
Method not allowed
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/notfound.html
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/notfound.html
vendored
Executable file
@@ -0,0 +1 @@
|
||||
File not found
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/one.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/one.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
one%
|
||||
0
.rclone_repo/cmd/serve/http/testdata/golden/onehead.txt
vendored
Executable file
0
.rclone_repo/cmd/serve/http/testdata/golden/onehead.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/onepost.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/onepost.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
Method not allowed
|
||||
12
.rclone_repo/cmd/serve/http/testdata/golden/three.html
vendored
Executable file
12
.rclone_repo/cmd/serve/http/testdata/golden/three.html
vendored
Executable file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Directory listing of /three</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Directory listing of /three</h1>
|
||||
<a href="a.txt">a.txt</a><br />
|
||||
<a href="b.txt">b.txt</a><br />
|
||||
</body>
|
||||
</html>
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/two-6.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/two-6.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
0123456
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/two.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/two.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
0123456789
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/two2-5.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/two2-5.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
2345
|
||||
1
.rclone_repo/cmd/serve/http/testdata/golden/two3-.txt
vendored
Executable file
1
.rclone_repo/cmd/serve/http/testdata/golden/two3-.txt
vendored
Executable file
@@ -0,0 +1 @@
|
||||
3456789
|
||||
21
.rclone_repo/cmd/serve/httplib/http_new.go
Executable file
21
.rclone_repo/cmd/serve/httplib/http_new.go
Executable file
@@ -0,0 +1,21 @@
|
||||
// HTTP parts go1.8+
|
||||
|
||||
//+build go1.8
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Initialise the http.Server for post go1.8
|
||||
func initServer(s *http.Server) {
|
||||
s.ReadHeaderTimeout = 10 * time.Second // time to send the headers
|
||||
s.IdleTimeout = 60 * time.Second // time to keep idle connections open
|
||||
}
|
||||
|
||||
// closeServer closes the server in a non graceful way
|
||||
func closeServer(s *http.Server) error {
|
||||
return s.Close()
|
||||
}
|
||||
18
.rclone_repo/cmd/serve/httplib/http_old.go
Executable file
18
.rclone_repo/cmd/serve/httplib/http_old.go
Executable file
@@ -0,0 +1,18 @@
|
||||
// HTTP parts pre go1.8
|
||||
|
||||
//+build !go1.8
|
||||
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Initialise the http.Server for pre go1.8
|
||||
func initServer(s *http.Server) {
|
||||
}
|
||||
|
||||
// closeServer closes the server in a non graceful way
|
||||
func closeServer(s *http.Server) error {
|
||||
return nil
|
||||
}
|
||||
32
.rclone_repo/cmd/serve/httplib/httpflags/httpflags.go
Executable file
32
.rclone_repo/cmd/serve/httplib/httpflags/httpflags.go
Executable file
@@ -0,0 +1,32 @@
|
||||
package httpflags
|
||||
|
||||
import (
|
||||
"github.com/ncw/rclone/cmd/serve/httplib"
|
||||
"github.com/ncw/rclone/fs/config/flags"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Options set by command line flags
|
||||
var (
|
||||
Opt = httplib.DefaultOpt
|
||||
)
|
||||
|
||||
// AddFlagsPrefix adds flags for the httplib
|
||||
func AddFlagsPrefix(flagSet *pflag.FlagSet, prefix string, Opt *httplib.Options) {
|
||||
flags.StringVarP(flagSet, &Opt.ListenAddr, prefix+"addr", "", Opt.ListenAddr, "IPaddress:Port or :Port to bind server to.")
|
||||
flags.DurationVarP(flagSet, &Opt.ServerReadTimeout, prefix+"server-read-timeout", "", Opt.ServerReadTimeout, "Timeout for server reading data")
|
||||
flags.DurationVarP(flagSet, &Opt.ServerWriteTimeout, prefix+"server-write-timeout", "", Opt.ServerWriteTimeout, "Timeout for server writing data")
|
||||
flags.IntVarP(flagSet, &Opt.MaxHeaderBytes, prefix+"max-header-bytes", "", Opt.MaxHeaderBytes, "Maximum size of request header")
|
||||
flags.StringVarP(flagSet, &Opt.SslCert, prefix+"cert", "", Opt.SslCert, "SSL PEM key (concatenation of certificate and CA certificate)")
|
||||
flags.StringVarP(flagSet, &Opt.SslKey, prefix+"key", "", Opt.SslKey, "SSL PEM Private key")
|
||||
flags.StringVarP(flagSet, &Opt.ClientCA, prefix+"client-ca", "", Opt.ClientCA, "Client certificate authority to verify clients with")
|
||||
flags.StringVarP(flagSet, &Opt.HtPasswd, prefix+"htpasswd", "", Opt.HtPasswd, "htpasswd file - if not provided no authentication is done")
|
||||
flags.StringVarP(flagSet, &Opt.Realm, prefix+"realm", "", Opt.Realm, "realm for authentication")
|
||||
flags.StringVarP(flagSet, &Opt.BasicUser, prefix+"user", "", Opt.BasicUser, "User name for authentication.")
|
||||
flags.StringVarP(flagSet, &Opt.BasicPass, prefix+"pass", "", Opt.BasicPass, "Password for authentication.")
|
||||
}
|
||||
|
||||
// AddFlags adds flags for the httplib
|
||||
func AddFlags(flagSet *pflag.FlagSet) {
|
||||
AddFlagsPrefix(flagSet, "", &Opt)
|
||||
}
|
||||
256
.rclone_repo/cmd/serve/httplib/httplib.go
Executable file
256
.rclone_repo/cmd/serve/httplib/httplib.go
Executable file
@@ -0,0 +1,256 @@
|
||||
// Package httplib provides common functionality for http servers
|
||||
package httplib
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
auth "github.com/abbot/go-http-auth"
|
||||
"github.com/ncw/rclone/fs"
|
||||
)
|
||||
|
||||
// Globals
|
||||
var ()
|
||||
|
||||
// Help contains text describing the http server to add to the command
|
||||
// help.
|
||||
var Help = `
|
||||
### Server options
|
||||
|
||||
Use --addr to specify which IP address and port the server should
|
||||
listen on, eg --addr 1.2.3.4:8000 or --addr :8080 to listen to all
|
||||
IPs. By default it only listens on localhost. You can use port
|
||||
:0 to let the OS choose an available port.
|
||||
|
||||
If you set --addr to listen on a public or LAN accessible IP address
|
||||
then using Authentication is advised - see the next section for info.
|
||||
|
||||
--server-read-timeout and --server-write-timeout can be used to
|
||||
control the timeouts on the server. Note that this is the total time
|
||||
for a transfer.
|
||||
|
||||
--max-header-bytes controls the maximum number of bytes the server will
|
||||
accept in the HTTP header.
|
||||
|
||||
#### Authentication
|
||||
|
||||
By default this will serve files without needing a login.
|
||||
|
||||
You can either use an htpasswd file which can take lots of users, or
|
||||
set a single username and password with the --user and --pass flags.
|
||||
|
||||
Use --htpasswd /path/to/htpasswd to provide an htpasswd file. This is
|
||||
in standard apache format and supports MD5, SHA1 and BCrypt for basic
|
||||
authentication. Bcrypt is recommended.
|
||||
|
||||
To create an htpasswd file:
|
||||
|
||||
touch htpasswd
|
||||
htpasswd -B htpasswd user
|
||||
htpasswd -B htpasswd anotherUser
|
||||
|
||||
The password file can be updated while rclone is running.
|
||||
|
||||
Use --realm to set the authentication realm.
|
||||
|
||||
#### SSL/TLS
|
||||
|
||||
By default this will serve over http. If you want you can serve over
|
||||
https. You will need to supply the --cert and --key flags. If you
|
||||
wish to do client side certificate validation then you will need to
|
||||
supply --client-ca also.
|
||||
|
||||
--cert should be a either a PEM encoded certificate or a concatenation
|
||||
of that with the CA certificate. --key should be the PEM encoded
|
||||
private key and --client-ca should be the PEM encoded client
|
||||
certificate authority certificate.
|
||||
`
|
||||
|
||||
// Options contains options for the http Server
|
||||
type Options struct {
|
||||
ListenAddr string // Port to listen on
|
||||
ServerReadTimeout time.Duration // Timeout for server reading data
|
||||
ServerWriteTimeout time.Duration // Timeout for server writing data
|
||||
MaxHeaderBytes int // Maximum size of request header
|
||||
SslCert string // SSL PEM key (concatenation of certificate and CA certificate)
|
||||
SslKey string // SSL PEM Private key
|
||||
ClientCA string // Client certificate authority to verify clients with
|
||||
HtPasswd string // htpasswd file - if not provided no authentication is done
|
||||
Realm string // realm for authentication
|
||||
BasicUser string // single username for basic auth if not using Htpasswd
|
||||
BasicPass string // password for BasicUser
|
||||
}
|
||||
|
||||
// DefaultOpt is the default values used for Options
|
||||
var DefaultOpt = Options{
|
||||
ListenAddr: "localhost:8080",
|
||||
Realm: "rclone",
|
||||
ServerReadTimeout: 1 * time.Hour,
|
||||
ServerWriteTimeout: 1 * time.Hour,
|
||||
MaxHeaderBytes: 4096,
|
||||
}
|
||||
|
||||
// Server contains info about the running http server
|
||||
type Server struct {
|
||||
Opt Options
|
||||
handler http.Handler // original handler
|
||||
listener net.Listener
|
||||
waitChan chan struct{} // for waiting on the listener to close
|
||||
httpServer *http.Server
|
||||
basicPassHashed string
|
||||
useSSL bool // if server is configured for SSL/TLS
|
||||
}
|
||||
|
||||
// singleUserProvider provides the encrypted password for a single user
|
||||
func (s *Server) singleUserProvider(user, realm string) string {
|
||||
if user == s.Opt.BasicUser {
|
||||
return s.basicPassHashed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewServer creates an http server. The opt can be nil in which case
|
||||
// the default options will be used.
|
||||
func NewServer(handler http.Handler, opt *Options) *Server {
|
||||
s := &Server{
|
||||
handler: handler,
|
||||
}
|
||||
|
||||
// Make a copy of the options
|
||||
if opt != nil {
|
||||
s.Opt = *opt
|
||||
} else {
|
||||
s.Opt = DefaultOpt
|
||||
}
|
||||
|
||||
// Use htpasswd if required on everything
|
||||
if s.Opt.HtPasswd != "" || s.Opt.BasicUser != "" {
|
||||
var secretProvider auth.SecretProvider
|
||||
if s.Opt.HtPasswd != "" {
|
||||
fs.Infof(nil, "Using %q as htpasswd storage", s.Opt.HtPasswd)
|
||||
secretProvider = auth.HtpasswdFileProvider(s.Opt.HtPasswd)
|
||||
} else {
|
||||
fs.Infof(nil, "Using --user %s --pass XXXX as authenticated user", s.Opt.BasicUser)
|
||||
s.basicPassHashed = string(auth.MD5Crypt([]byte(s.Opt.BasicPass), []byte("dlPL2MqE"), []byte("$1$")))
|
||||
secretProvider = s.singleUserProvider
|
||||
}
|
||||
authenticator := auth.NewBasicAuthenticator(s.Opt.Realm, secretProvider)
|
||||
handler = auth.JustCheck(authenticator, handler.ServeHTTP)
|
||||
}
|
||||
|
||||
s.useSSL = s.Opt.SslKey != ""
|
||||
if (s.Opt.SslCert != "") != s.useSSL {
|
||||
log.Fatalf("Need both -cert and -key to use SSL")
|
||||
}
|
||||
|
||||
// FIXME make a transport?
|
||||
s.httpServer = &http.Server{
|
||||
Addr: s.Opt.ListenAddr,
|
||||
Handler: handler,
|
||||
ReadTimeout: s.Opt.ServerReadTimeout,
|
||||
WriteTimeout: s.Opt.ServerWriteTimeout,
|
||||
MaxHeaderBytes: s.Opt.MaxHeaderBytes,
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS10, // disable SSL v3.0 and earlier
|
||||
},
|
||||
}
|
||||
// go version specific initialisation
|
||||
initServer(s.httpServer)
|
||||
|
||||
if s.Opt.ClientCA != "" {
|
||||
if !s.useSSL {
|
||||
log.Fatalf("Can't use --client-ca without --cert and --key")
|
||||
}
|
||||
certpool := x509.NewCertPool()
|
||||
pem, err := ioutil.ReadFile(s.Opt.ClientCA)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read client certificate authority: %v", err)
|
||||
}
|
||||
if !certpool.AppendCertsFromPEM(pem) {
|
||||
log.Fatalf("Can't parse client certificate authority")
|
||||
}
|
||||
s.httpServer.TLSConfig.ClientCAs = certpool
|
||||
s.httpServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Serve runs the server - returns an error only if
|
||||
// the listener was not started; does not block, so
|
||||
// use s.Wait() to block on the listener indefinitely.
|
||||
func (s *Server) Serve() error {
|
||||
ln, err := net.Listen("tcp", s.httpServer.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.listener = ln
|
||||
s.waitChan = make(chan struct{})
|
||||
go func() {
|
||||
var err error
|
||||
if s.useSSL {
|
||||
// hacky hack to get this to work with old Go versions, which
|
||||
// don't have ServeTLS on http.Server; see PR #2194.
|
||||
type tlsServer interface {
|
||||
ServeTLS(ln net.Listener, cert, key string) error
|
||||
}
|
||||
srvIface := interface{}(s.httpServer)
|
||||
if tlsSrv, ok := srvIface.(tlsServer); ok {
|
||||
// yay -- we get easy TLS support with HTTP/2
|
||||
err = tlsSrv.ServeTLS(s.listener, s.Opt.SslCert, s.Opt.SslKey)
|
||||
} else {
|
||||
// oh well -- we can still do TLS but might not have HTTP/2
|
||||
tlsConfig := new(tls.Config)
|
||||
tlsConfig.Certificates = make([]tls.Certificate, 1)
|
||||
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(s.Opt.SslCert, s.Opt.SslKey)
|
||||
if err != nil {
|
||||
log.Printf("Error loading key pair: %v", err)
|
||||
}
|
||||
tlsLn := tls.NewListener(s.listener, tlsConfig)
|
||||
err = s.httpServer.Serve(tlsLn)
|
||||
}
|
||||
} else {
|
||||
err = s.httpServer.Serve(s.listener)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Error on serving HTTP server: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks while the listener is open.
|
||||
func (s *Server) Wait() {
|
||||
<-s.waitChan
|
||||
}
|
||||
|
||||
// Close shuts the running server down
|
||||
func (s *Server) Close() {
|
||||
err := closeServer(s.httpServer)
|
||||
if err != nil {
|
||||
log.Printf("Error on closing HTTP server: %v", err)
|
||||
return
|
||||
}
|
||||
close(s.waitChan)
|
||||
}
|
||||
|
||||
// URL returns the serving address of this server
|
||||
func (s *Server) URL() string {
|
||||
proto := "http"
|
||||
if s.useSSL {
|
||||
proto = "https"
|
||||
}
|
||||
addr := s.Opt.ListenAddr
|
||||
if s.listener != nil {
|
||||
// prefer actual listener address; required if using 0-port
|
||||
// (i.e. port assigned by operating system)
|
||||
addr = s.listener.Addr().String()
|
||||
}
|
||||
return fmt.Sprintf("%s://%s/", proto, addr)
|
||||
}
|
||||
35
.rclone_repo/cmd/serve/restic/restic-test.sh
Executable file
35
.rclone_repo/cmd/serve/restic/restic-test.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Test all the remotes against restic integration test
|
||||
# Run with: screen -S restic-test -L ./restic-test.sh
|
||||
|
||||
remotes="
|
||||
TestAzureBlob:
|
||||
TestB2:
|
||||
TestBox:
|
||||
TestCache:
|
||||
TestCryptDrive:
|
||||
TestCryptSwift:
|
||||
TestDrive:
|
||||
TestDropbox:
|
||||
TestFTP:
|
||||
TestGoogleCloudStorage:
|
||||
TestHubic:
|
||||
TestOneDrive:
|
||||
TestPcloud:
|
||||
TestQingStor:
|
||||
TestS3:
|
||||
TestSftp:
|
||||
TestSwift:
|
||||
TestWebdav:
|
||||
TestYandex:
|
||||
"
|
||||
|
||||
# TestOss:
|
||||
# TestMega:
|
||||
|
||||
for remote in $remotes; do
|
||||
echo `date -Is` $remote starting
|
||||
go test -remote $remote -v -timeout 30m 2>&1 | tee restic-test.$remote.log
|
||||
echo `date -Is` $remote ending
|
||||
done
|
||||
471
.rclone_repo/cmd/serve/restic/restic.go
Executable file
471
.rclone_repo/cmd/serve/restic/restic.go
Executable file
@@ -0,0 +1,471 @@
|
||||
// Package restic serves a remote suitable for use with restic
|
||||
package restic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/ncw/rclone/cmd"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib/httpflags"
|
||||
"github.com/ncw/rclone/fs"
|
||||
"github.com/ncw/rclone/fs/accounting"
|
||||
"github.com/ncw/rclone/fs/fserrors"
|
||||
"github.com/ncw/rclone/fs/operations"
|
||||
"github.com/ncw/rclone/fs/walk"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
var (
|
||||
stdio bool
|
||||
appendOnly bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpflags.AddFlags(Command.Flags())
|
||||
Command.Flags().BoolVar(&stdio, "stdio", false, "run an HTTP2 server on stdin/stdout")
|
||||
Command.Flags().BoolVar(&appendOnly, "append-only", false, "disallow deletion of repository data")
|
||||
}
|
||||
|
||||
// Command definition for cobra
|
||||
var Command = &cobra.Command{
|
||||
Use: "restic remote:path",
|
||||
Short: `Serve the remote for restic's REST API.`,
|
||||
Long: `rclone serve restic implements restic's REST backend API
|
||||
over HTTP. This allows restic to use rclone as a data storage
|
||||
mechanism for cloud providers that restic does not support directly.
|
||||
|
||||
[Restic](https://restic.net/) is a command line program for doing
|
||||
backups.
|
||||
|
||||
The server will log errors. Use -v to see access logs.
|
||||
|
||||
--bwlimit will be respected for file transfers. Use --stats to
|
||||
control the stats printing.
|
||||
|
||||
### Setting up rclone for use by restic ###
|
||||
|
||||
First [set up a remote for your chosen cloud provider](/docs/#configure).
|
||||
|
||||
Once you have set up the remote, check it is working with, for example
|
||||
"rclone lsd remote:". You may have called the remote something other
|
||||
than "remote:" - just substitute whatever you called it in the
|
||||
following instructions.
|
||||
|
||||
Now start the rclone restic server
|
||||
|
||||
rclone serve restic -v remote:backup
|
||||
|
||||
Where you can replace "backup" in the above by whatever path in the
|
||||
remote you wish to use.
|
||||
|
||||
By default this will serve on "localhost:8080" you can change this
|
||||
with use of the "--addr" flag.
|
||||
|
||||
You might wish to start this server on boot.
|
||||
|
||||
### Setting up restic to use rclone ###
|
||||
|
||||
Now you can [follow the restic
|
||||
instructions](http://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#rest-server)
|
||||
on setting up restic.
|
||||
|
||||
Note that you will need restic 0.8.2 or later to interoperate with
|
||||
rclone.
|
||||
|
||||
For the example above you will want to use "http://localhost:8080/" as
|
||||
the URL for the REST server.
|
||||
|
||||
For example:
|
||||
|
||||
$ export RESTIC_REPOSITORY=rest:http://localhost:8080/
|
||||
$ export RESTIC_PASSWORD=yourpassword
|
||||
$ restic init
|
||||
created restic backend 8b1a4b56ae at rest:http://localhost:8080/
|
||||
|
||||
Please note that knowledge of your password is required to access
|
||||
the repository. Losing your password means that your data is
|
||||
irrecoverably lost.
|
||||
$ restic backup /path/to/files/to/backup
|
||||
scan [/path/to/files/to/backup]
|
||||
scanned 189 directories, 312 files in 0:00
|
||||
[0:00] 100.00% 38.128 MiB / 38.128 MiB 501 / 501 items 0 errors ETA 0:00
|
||||
duration: 0:00
|
||||
snapshot 45c8fdd8 saved
|
||||
|
||||
#### Multiple repositories ####
|
||||
|
||||
Note that you can use the endpoint to host multiple repositories. Do
|
||||
this by adding a directory name or path after the URL. Note that
|
||||
these **must** end with /. Eg
|
||||
|
||||
$ export RESTIC_REPOSITORY=rest:http://localhost:8080/user1repo/
|
||||
# backup user1 stuff
|
||||
$ export RESTIC_REPOSITORY=rest:http://localhost:8080/user2repo/
|
||||
# backup user2 stuff
|
||||
|
||||
` + httplib.Help,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
cmd.CheckArgs(1, 1, command, args)
|
||||
f := cmd.NewFsSrc(args)
|
||||
cmd.Run(false, true, command, func() error {
|
||||
s := newServer(f, &httpflags.Opt)
|
||||
if stdio {
|
||||
if terminal.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return errors.New("Refusing to run HTTP2 server directly on a terminal, please let restic start rclone")
|
||||
}
|
||||
|
||||
conn := &StdioConn{
|
||||
stdin: os.Stdin,
|
||||
stdout: os.Stdout,
|
||||
}
|
||||
|
||||
httpSrv := &http2.Server{}
|
||||
opts := &http2.ServeConnOpts{
|
||||
Handler: http.HandlerFunc(s.handler),
|
||||
}
|
||||
httpSrv.ServeConn(conn, opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
s.serve()
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
resticAPIV2 = "application/vnd.x.restic.rest.v2"
|
||||
)
|
||||
|
||||
// server contains everything to run the server
|
||||
type server struct {
|
||||
f fs.Fs
|
||||
srv *httplib.Server
|
||||
}
|
||||
|
||||
func newServer(f fs.Fs, opt *httplib.Options) *server {
|
||||
mux := http.NewServeMux()
|
||||
s := &server{
|
||||
f: f,
|
||||
srv: httplib.NewServer(mux, opt),
|
||||
}
|
||||
mux.HandleFunc("/", s.handler)
|
||||
return s
|
||||
}
|
||||
|
||||
// serve runs the http server - doesn't return
|
||||
func (s *server) serve() {
|
||||
err := s.srv.Serve()
|
||||
if err != nil {
|
||||
fs.Errorf(s.f, "Opening listener: %v", err)
|
||||
}
|
||||
fs.Logf(s.f, "Serving restic REST API on %s", s.srv.URL())
|
||||
s.srv.Wait()
|
||||
}
|
||||
|
||||
var matchData = regexp.MustCompile("(?:^|/)data/([^/]{2,})$")
|
||||
|
||||
// Makes a remote from a URL path. This implements the backend layout
|
||||
// required by restic.
|
||||
func makeRemote(path string) string {
|
||||
path = strings.Trim(path, "/")
|
||||
parts := matchData.FindStringSubmatch(path)
|
||||
// if no data directory, layout is flat
|
||||
if parts == nil {
|
||||
return path
|
||||
}
|
||||
// otherwise map
|
||||
// data/2159dd48 to
|
||||
// data/21/2159dd48
|
||||
fileName := parts[1]
|
||||
prefix := path[:len(path)-len(fileName)]
|
||||
return prefix + fileName[:2] + "/" + fileName
|
||||
}
|
||||
|
||||
// handler reads incoming requests and dispatches them
|
||||
func (s *server) handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Server", "rclone/"+fs.Version)
|
||||
|
||||
path := r.URL.Path
|
||||
remote := makeRemote(path)
|
||||
fs.Debugf(s.f, "%s %s", r.Method, path)
|
||||
|
||||
// Dispatch on path then method
|
||||
if strings.HasSuffix(path, "/") {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
s.listObjects(w, r, remote)
|
||||
case "POST":
|
||||
s.createRepo(w, r, remote)
|
||||
default:
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
} else {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
s.getObject(w, r, remote)
|
||||
case "HEAD":
|
||||
s.headObject(w, r, remote)
|
||||
case "POST":
|
||||
s.postObject(w, r, remote)
|
||||
case "DELETE":
|
||||
s.deleteObject(w, r, remote)
|
||||
default:
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// head request the remote
|
||||
func (s *server) headObject(w http.ResponseWriter, r *http.Request, remote string) {
|
||||
o, err := s.f.NewObject(remote)
|
||||
if err != nil {
|
||||
fs.Debugf(remote, "Head request error: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Set content length since we know how long the object is
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(o.Size(), 10))
|
||||
}
|
||||
|
||||
// get the remote
|
||||
func (s *server) getObject(w http.ResponseWriter, r *http.Request, remote string) {
|
||||
o, err := s.f.NewObject(remote)
|
||||
if err != nil {
|
||||
fs.Debugf(remote, "Get request error: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Set content length since we know how long the object is
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(o.Size(), 10))
|
||||
|
||||
// Decode Range request if present
|
||||
code := http.StatusOK
|
||||
size := o.Size()
|
||||
var options []fs.OpenOption
|
||||
if rangeRequest := r.Header.Get("Range"); rangeRequest != "" {
|
||||
//fs.Debugf(nil, "Range: request %q", rangeRequest)
|
||||
option, err := fs.ParseRangeOption(rangeRequest)
|
||||
if err != nil {
|
||||
fs.Debugf(remote, "Get request parse range request error: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
options = append(options, option)
|
||||
offset, limit := option.Decode(o.Size())
|
||||
end := o.Size() // exclusive
|
||||
if limit >= 0 {
|
||||
end = offset + limit
|
||||
}
|
||||
if end > o.Size() {
|
||||
end = o.Size()
|
||||
}
|
||||
size = end - offset
|
||||
// fs.Debugf(nil, "Range: offset=%d, limit=%d, end=%d, size=%d (object size %d)", offset, limit, end, size, o.Size())
|
||||
// Content-Range: bytes 0-1023/146515
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, end-1, o.Size()))
|
||||
// fs.Debugf(nil, "Range: Content-Range: %q", w.Header().Get("Content-Range"))
|
||||
code = http.StatusPartialContent
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
|
||||
file, err := o.Open(options...)
|
||||
if err != nil {
|
||||
fs.Debugf(remote, "Get request open error: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
accounting.Stats.Transferring(o.Remote())
|
||||
in := accounting.NewAccount(file, o) // account the transfer (no buffering)
|
||||
defer func() {
|
||||
closeErr := in.Close()
|
||||
if closeErr != nil {
|
||||
fs.Errorf(remote, "Get request: close failed: %v", closeErr)
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}
|
||||
ok := err == nil
|
||||
accounting.Stats.DoneTransferring(o.Remote(), ok)
|
||||
if !ok {
|
||||
accounting.Stats.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.WriteHeader(code)
|
||||
|
||||
n, err := io.Copy(w, in)
|
||||
if err != nil {
|
||||
fs.Errorf(remote, "Didn't finish writing GET request (wrote %d/%d bytes): %v", n, size, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// postObject posts an object to the repository
|
||||
func (s *server) postObject(w http.ResponseWriter, r *http.Request, remote string) {
|
||||
if appendOnly {
|
||||
// make sure the file does not exist yet
|
||||
_, err := s.f.NewObject(remote)
|
||||
if err == nil {
|
||||
fs.Errorf(remote, "Post request: file already exists, refusing to overwrite in append-only mode")
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err := operations.RcatSize(s.f, remote, r.Body, r.ContentLength, time.Now())
|
||||
if err != nil {
|
||||
accounting.Stats.Error(err)
|
||||
fs.Errorf(remote, "Post request rcat error: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// delete the remote
|
||||
func (s *server) deleteObject(w http.ResponseWriter, r *http.Request, remote string) {
|
||||
if appendOnly {
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
|
||||
// if path doesn't end in "/locks/:name", disallow the operation
|
||||
if len(parts) < 2 || parts[len(parts)-2] != "locks" {
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
o, err := s.f.NewObject(remote)
|
||||
if err != nil {
|
||||
fs.Debugf(remote, "Delete request error: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := o.Remove(); err != nil {
|
||||
fs.Errorf(remote, "Delete request remove error: %v", err)
|
||||
if err == fs.ErrorObjectNotFound {
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// listItem is an element returned for the restic v2 list response
|
||||
type listItem struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// return type for list
|
||||
type listItems []listItem
|
||||
|
||||
// add a DirEntry to the listItems
|
||||
func (ls *listItems) add(entry fs.DirEntry) {
|
||||
if o, ok := entry.(fs.Object); ok {
|
||||
*ls = append(*ls, listItem{
|
||||
Name: path.Base(o.Remote()),
|
||||
Size: o.Size(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// listObjects lists all Objects of a given type in an arbitrary order.
|
||||
func (s *server) listObjects(w http.ResponseWriter, r *http.Request, remote string) {
|
||||
fs.Debugf(remote, "list request")
|
||||
|
||||
if r.Header.Get("Accept") != resticAPIV2 {
|
||||
fs.Errorf(remote, "Restic v2 API required")
|
||||
http.Error(w, "Restic v2 API required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// make sure an empty list is returned, and not a 'nil' value
|
||||
ls := listItems{}
|
||||
|
||||
// if remote supports ListR use that directly, otherwise use recursive Walk
|
||||
var err error
|
||||
if ListR := s.f.Features().ListR; ListR != nil {
|
||||
err = ListR(remote, func(entries fs.DirEntries) error {
|
||||
for _, entry := range entries {
|
||||
ls.add(entry)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
err = walk.Walk(s.f, remote, true, -1, func(path string, entries fs.DirEntries, err error) error {
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
ls.add(entry)
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_, err = fserrors.Cause(err)
|
||||
if err != fs.ErrorDirNotFound {
|
||||
fs.Errorf(remote, "list failed: %#v %T", err, err)
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.x.restic.rest.v2")
|
||||
enc := json.NewEncoder(w)
|
||||
err = enc.Encode(ls)
|
||||
if err != nil {
|
||||
fs.Errorf(remote, "failed to write list: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// createRepo creates repository directories.
|
||||
//
|
||||
// We don't bother creating the data dirs as rclone will create them on the fly
|
||||
func (s *server) createRepo(w http.ResponseWriter, r *http.Request, remote string) {
|
||||
fs.Infof(remote, "Creating repository")
|
||||
|
||||
if r.URL.Query().Get("create") != "true" {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := s.f.Mkdir(remote)
|
||||
if err != nil {
|
||||
fs.Errorf(remote, "Create repo failed to Mkdir: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range []string{"data", "index", "keys", "locks", "snapshots"} {
|
||||
dirRemote := path.Join(remote, name)
|
||||
err := s.f.Mkdir(dirRemote)
|
||||
if err != nil {
|
||||
fs.Errorf(dirRemote, "Create repo failed to Mkdir: %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
190
.rclone_repo/cmd/serve/restic/restic_appendonly_test.go
Executable file
190
.rclone_repo/cmd/serve/restic/restic_appendonly_test.go
Executable file
@@ -0,0 +1,190 @@
|
||||
package restic
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ncw/rclone/cmd"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib/httpflags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// declare a few helper functions
|
||||
|
||||
// wantFunc tests the HTTP response in res and marks the test as errored if something is incorrect.
|
||||
type wantFunc func(t testing.TB, res *httptest.ResponseRecorder)
|
||||
|
||||
// newRequest returns a new HTTP request with the given params. On error, the
|
||||
// test is marked as failed.
|
||||
func newRequest(t testing.TB, method, path string, body io.Reader) *http.Request {
|
||||
req, err := http.NewRequest(method, path, body)
|
||||
require.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
|
||||
// wantCode returns a function which checks that the response has the correct HTTP status code.
|
||||
func wantCode(code int) wantFunc {
|
||||
return func(t testing.TB, res *httptest.ResponseRecorder) {
|
||||
assert.Equal(t, code, res.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// wantBody returns a function which checks that the response has the data in the body.
|
||||
func wantBody(body string) wantFunc {
|
||||
return func(t testing.TB, res *httptest.ResponseRecorder) {
|
||||
assert.NotNil(t, res.Body)
|
||||
assert.Equal(t, res.Body.Bytes(), []byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
// checkRequest uses f to process the request and runs the checker functions on the result.
|
||||
func checkRequest(t testing.TB, f http.HandlerFunc, req *http.Request, want []wantFunc) {
|
||||
rr := httptest.NewRecorder()
|
||||
f(rr, req)
|
||||
|
||||
for _, fn := range want {
|
||||
fn(t, rr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequest is a sequence of HTTP requests with (optional) tests for the response.
|
||||
type TestRequest struct {
|
||||
req *http.Request
|
||||
want []wantFunc
|
||||
}
|
||||
|
||||
// createOverwriteDeleteSeq returns a sequence which will create a new file at
|
||||
// path, and then try to overwrite and delete it.
|
||||
func createOverwriteDeleteSeq(t testing.TB, path string) []TestRequest {
|
||||
// add a file, try to overwrite and delete it
|
||||
req := []TestRequest{
|
||||
{
|
||||
req: newRequest(t, "GET", path, nil),
|
||||
want: []wantFunc{wantCode(http.StatusNotFound)},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "POST", path, strings.NewReader("foobar test config")),
|
||||
want: []wantFunc{wantCode(http.StatusOK)},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "GET", path, nil),
|
||||
want: []wantFunc{
|
||||
wantCode(http.StatusOK),
|
||||
wantBody("foobar test config"),
|
||||
},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "POST", path, strings.NewReader("other config")),
|
||||
want: []wantFunc{wantCode(http.StatusForbidden)},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "GET", path, nil),
|
||||
want: []wantFunc{
|
||||
wantCode(http.StatusOK),
|
||||
wantBody("foobar test config"),
|
||||
},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "DELETE", path, nil),
|
||||
want: []wantFunc{wantCode(http.StatusForbidden)},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "GET", path, nil),
|
||||
want: []wantFunc{
|
||||
wantCode(http.StatusOK),
|
||||
wantBody("foobar test config"),
|
||||
},
|
||||
},
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// TestResticHandler runs tests on the restic handler code, especially in append-only mode.
|
||||
func TestResticHandler(t *testing.T) {
|
||||
buf := make([]byte, 32)
|
||||
_, err := io.ReadFull(rand.Reader, buf)
|
||||
require.NoError(t, err)
|
||||
randomID := hex.EncodeToString(buf)
|
||||
|
||||
var tests = []struct {
|
||||
seq []TestRequest
|
||||
}{
|
||||
{createOverwriteDeleteSeq(t, "/config")},
|
||||
{createOverwriteDeleteSeq(t, "/data/"+randomID)},
|
||||
{
|
||||
// ensure we can add and remove lock files
|
||||
[]TestRequest{
|
||||
{
|
||||
req: newRequest(t, "GET", "/locks/"+randomID, nil),
|
||||
want: []wantFunc{wantCode(http.StatusNotFound)},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "POST", "/locks/"+randomID, strings.NewReader("lock file")),
|
||||
want: []wantFunc{wantCode(http.StatusOK)},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "GET", "/locks/"+randomID, nil),
|
||||
want: []wantFunc{
|
||||
wantCode(http.StatusOK),
|
||||
wantBody("lock file"),
|
||||
},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "POST", "/locks/"+randomID, strings.NewReader("other lock file")),
|
||||
want: []wantFunc{wantCode(http.StatusForbidden)},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "DELETE", "/locks/"+randomID, nil),
|
||||
want: []wantFunc{wantCode(http.StatusOK)},
|
||||
},
|
||||
{
|
||||
req: newRequest(t, "GET", "/locks/"+randomID, nil),
|
||||
want: []wantFunc{wantCode(http.StatusNotFound)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// setup rclone with a local backend in a temporary directory
|
||||
tempdir, err := ioutil.TempDir("", "rclone-restic-test-")
|
||||
require.NoError(t, err)
|
||||
|
||||
// make sure the tempdir is properly removed
|
||||
defer func() {
|
||||
err := os.RemoveAll(tempdir)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
// globally set append-only mode
|
||||
prev := appendOnly
|
||||
appendOnly = true
|
||||
defer func() {
|
||||
appendOnly = prev // reset when done
|
||||
}()
|
||||
|
||||
// make a new file system in the temp dir
|
||||
f := cmd.NewFsSrc([]string{tempdir})
|
||||
srv := newServer(f, &httpflags.Opt)
|
||||
|
||||
// create the repo
|
||||
checkRequest(t, srv.handler,
|
||||
newRequest(t, "POST", "/?create=true", nil),
|
||||
[]wantFunc{wantCode(http.StatusOK)})
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run("", func(t *testing.T) {
|
||||
for i, seq := range test.seq {
|
||||
t.Logf("request %v: %v %v", i, seq.req.Method, seq.req.URL.Path)
|
||||
checkRequest(t, srv.handler, seq.req, seq.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
95
.rclone_repo/cmd/serve/restic/restic_test.go
Executable file
95
.rclone_repo/cmd/serve/restic/restic_test.go
Executable file
@@ -0,0 +1,95 @@
|
||||
// Serve restic tests set up a server and run the integration tests
|
||||
// for restic against it.
|
||||
package restic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
_ "github.com/ncw/rclone/backend/all"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib"
|
||||
"github.com/ncw/rclone/fstest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
testBindAddress = "localhost:51779"
|
||||
testURL = "http://" + testBindAddress + "/"
|
||||
resticSource = "../../../../../restic/restic"
|
||||
)
|
||||
|
||||
// TestRestic runs the restic server then runs the unit tests for the
|
||||
// restic remote against it.
|
||||
func TestRestic(t *testing.T) {
|
||||
_, err := os.Stat(resticSource)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test as restic source not found: %v", err)
|
||||
}
|
||||
|
||||
opt := httplib.DefaultOpt
|
||||
opt.ListenAddr = testBindAddress
|
||||
|
||||
fstest.Initialise()
|
||||
|
||||
fremote, _, clean, err := fstest.RandomRemote(*fstest.RemoteName, *fstest.SubDir)
|
||||
assert.NoError(t, err)
|
||||
defer clean()
|
||||
|
||||
err = fremote.Mkdir("")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Start the server
|
||||
w := newServer(fremote, &opt)
|
||||
go w.serve()
|
||||
defer w.srv.Close()
|
||||
|
||||
// Change directory to run the tests
|
||||
err = os.Chdir(resticSource)
|
||||
assert.NoError(t, err, "failed to cd to restic source code")
|
||||
|
||||
// Run the restic tests
|
||||
runTests := func(path string) {
|
||||
args := []string{"test", "./internal/backend/rest", "-run", "TestBackendRESTExternalServer", "-count=1"}
|
||||
if testing.Verbose() {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"RESTIC_TEST_REST_REPOSITORY=rest:"+testURL+path,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) != 0 {
|
||||
t.Logf("\n----------\n%s----------\n", string(out))
|
||||
}
|
||||
assert.NoError(t, err, "Running restic integration tests")
|
||||
}
|
||||
|
||||
// Run the tests with no path
|
||||
runTests("")
|
||||
//... and again with a path
|
||||
runTests("potato/sausage/")
|
||||
|
||||
}
|
||||
|
||||
func TestMakeRemote(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"/", ""},
|
||||
{"/data", "data"},
|
||||
{"/data/", "data"},
|
||||
{"/data/1", "data/1"},
|
||||
{"/data/12", "data/12/12"},
|
||||
{"/data/123", "data/12/123"},
|
||||
{"/data/123/", "data/12/123"},
|
||||
{"/keys", "keys"},
|
||||
{"/keys/1", "keys/1"},
|
||||
{"/keys/12", "keys/12"},
|
||||
{"/keys/123", "keys/123"},
|
||||
} {
|
||||
got := makeRemote(test.in)
|
||||
assert.Equal(t, test.want, got, test.in)
|
||||
}
|
||||
}
|
||||
52
.rclone_repo/cmd/serve/restic/stdio_conn.go
Executable file
52
.rclone_repo/cmd/serve/restic/stdio_conn.go
Executable file
@@ -0,0 +1,52 @@
|
||||
package restic
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Addr implements net.Addr for stdin/stdout.
|
||||
type Addr struct{}
|
||||
|
||||
// Network returns the network type as a string.
|
||||
func (a Addr) Network() string {
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
func (a Addr) String() string {
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
// StdioConn implements a net.Conn via stdin/stdout.
|
||||
type StdioConn struct {
|
||||
stdin *os.File
|
||||
stdout *os.File
|
||||
}
|
||||
|
||||
func (s *StdioConn) Read(p []byte) (int, error) {
|
||||
return s.stdin.Read(p)
|
||||
}
|
||||
|
||||
func (s *StdioConn) Write(p []byte) (int, error) {
|
||||
return s.stdout.Write(p)
|
||||
}
|
||||
|
||||
// Close closes both streams.
|
||||
func (s *StdioConn) Close() error {
|
||||
err1 := s.stdin.Close()
|
||||
err2 := s.stdout.Close()
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
return err2
|
||||
}
|
||||
|
||||
// LocalAddr returns nil.
|
||||
func (s *StdioConn) LocalAddr() net.Addr {
|
||||
return Addr{}
|
||||
}
|
||||
|
||||
// RemoteAddr returns nil.
|
||||
func (s *StdioConn) RemoteAddr() net.Addr {
|
||||
return Addr{}
|
||||
}
|
||||
27
.rclone_repo/cmd/serve/restic/stdio_conn_go1.10.go
Executable file
27
.rclone_repo/cmd/serve/restic/stdio_conn_go1.10.go
Executable file
@@ -0,0 +1,27 @@
|
||||
//+build go1.10
|
||||
|
||||
// Deadline setting for go1.10+
|
||||
|
||||
package restic
|
||||
|
||||
import "time"
|
||||
|
||||
// SetDeadline sets the read/write deadline.
|
||||
func (s *StdioConn) SetDeadline(t time.Time) error {
|
||||
err1 := s.stdin.SetReadDeadline(t)
|
||||
err2 := s.stdout.SetWriteDeadline(t)
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
return err2
|
||||
}
|
||||
|
||||
// SetReadDeadline sets the read/write deadline.
|
||||
func (s *StdioConn) SetReadDeadline(t time.Time) error {
|
||||
return s.stdin.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
// SetWriteDeadline sets the read/write deadline.
|
||||
func (s *StdioConn) SetWriteDeadline(t time.Time) error {
|
||||
return s.stdout.SetWriteDeadline(t)
|
||||
}
|
||||
22
.rclone_repo/cmd/serve/restic/stdio_conn_pre_go1.10.go
Executable file
22
.rclone_repo/cmd/serve/restic/stdio_conn_pre_go1.10.go
Executable file
@@ -0,0 +1,22 @@
|
||||
//+build !go1.10
|
||||
|
||||
// Fallback deadline setting for pre go1.10
|
||||
|
||||
package restic
|
||||
|
||||
import "time"
|
||||
|
||||
// SetDeadline sets the read/write deadline.
|
||||
func (s *StdioConn) SetDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReadDeadline sets the read/write deadline.
|
||||
func (s *StdioConn) SetReadDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetWriteDeadline sets the read/write deadline.
|
||||
func (s *StdioConn) SetWriteDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
37
.rclone_repo/cmd/serve/serve.go
Executable file
37
.rclone_repo/cmd/serve/serve.go
Executable file
@@ -0,0 +1,37 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/ncw/rclone/cmd"
|
||||
"github.com/ncw/rclone/cmd/serve/http"
|
||||
"github.com/ncw/rclone/cmd/serve/restic"
|
||||
"github.com/ncw/rclone/cmd/serve/webdav"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Command.AddCommand(http.Command)
|
||||
Command.AddCommand(webdav.Command)
|
||||
Command.AddCommand(restic.Command)
|
||||
cmd.Root.AddCommand(Command)
|
||||
}
|
||||
|
||||
// Command definition for cobra
|
||||
var Command = &cobra.Command{
|
||||
Use: "serve <protocol> [opts] <remote>",
|
||||
Short: `Serve a remote over a protocol.`,
|
||||
Long: `rclone serve is used to serve a remote over a given protocol. This
|
||||
command requires the use of a subcommand to specify the protocol, eg
|
||||
|
||||
rclone serve http remote:
|
||||
|
||||
Each subcommand has its own options which you can see in their help.
|
||||
`,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("serve requires a protocol, eg 'rclone serve http remote:'")
|
||||
}
|
||||
return errors.New("unknown protocol")
|
||||
},
|
||||
}
|
||||
257
.rclone_repo/cmd/serve/webdav/webdav.go
Executable file
257
.rclone_repo/cmd/serve/webdav/webdav.go
Executable file
@@ -0,0 +1,257 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/ncw/rclone/cmd"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib/httpflags"
|
||||
"github.com/ncw/rclone/fs"
|
||||
"github.com/ncw/rclone/fs/hash"
|
||||
"github.com/ncw/rclone/fs/log"
|
||||
"github.com/ncw/rclone/vfs"
|
||||
"github.com/ncw/rclone/vfs/vfsflags"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/net/context" // switch to "context" when we stop supporting go1.8
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
var (
|
||||
hashName string
|
||||
hashType = hash.None
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpflags.AddFlags(Command.Flags())
|
||||
vfsflags.AddFlags(Command.Flags())
|
||||
Command.Flags().StringVar(&hashName, "etag-hash", "", "Which hash to use for the ETag, or auto or blank for off")
|
||||
}
|
||||
|
||||
// Command definition for cobra
|
||||
var Command = &cobra.Command{
|
||||
Use: "webdav remote:path",
|
||||
Short: `Serve remote:path over webdav.`,
|
||||
Long: `
|
||||
rclone serve webdav implements a basic webdav server to serve the
|
||||
remote over HTTP via the webdav protocol. This can be viewed with a
|
||||
webdav client or you can make a remote of type webdav to read and
|
||||
write it.
|
||||
|
||||
### Webdav options
|
||||
|
||||
#### --etag-hash
|
||||
|
||||
This controls the ETag header. Without this flag the ETag will be
|
||||
based on the ModTime and Size of the object.
|
||||
|
||||
If this flag is set to "auto" then rclone will choose the first
|
||||
supported hash on the backend or you can use a named hash such as
|
||||
"MD5" or "SHA-1".
|
||||
|
||||
Use "rclone hashsum" to see the full list.
|
||||
|
||||
` + httplib.Help + vfs.Help,
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
cmd.CheckArgs(1, 1, command, args)
|
||||
f := cmd.NewFsSrc(args)
|
||||
hashType = hash.None
|
||||
if hashName == "auto" {
|
||||
hashType = f.Hashes().GetOne()
|
||||
} else if hashName != "" {
|
||||
err := hashType.Set(hashName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hashType != hash.None {
|
||||
fs.Debugf(f, "Using hash %v for ETag", hashType)
|
||||
}
|
||||
cmd.Run(false, false, command, func() error {
|
||||
w := newWebDAV(f, &httpflags.Opt)
|
||||
w.serve()
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// WebDAV is a webdav.FileSystem interface
|
||||
//
|
||||
// A FileSystem implements access to a collection of named files. The elements
|
||||
// in a file path are separated by slash ('/', U+002F) characters, regardless
|
||||
// of host operating system convention.
|
||||
//
|
||||
// Each method has the same semantics as the os package's function of the same
|
||||
// name.
|
||||
//
|
||||
// Note that the os.Rename documentation says that "OS-specific restrictions
|
||||
// might apply". In particular, whether or not renaming a file or directory
|
||||
// overwriting another existing file or directory is an error is OS-dependent.
|
||||
type WebDAV struct {
|
||||
f fs.Fs
|
||||
vfs *vfs.VFS
|
||||
srv *httplib.Server
|
||||
}
|
||||
|
||||
// check interface
|
||||
var _ webdav.FileSystem = (*WebDAV)(nil)
|
||||
|
||||
// Make a new WebDAV to serve the remote
|
||||
func newWebDAV(f fs.Fs, opt *httplib.Options) *WebDAV {
|
||||
w := &WebDAV{
|
||||
f: f,
|
||||
vfs: vfs.New(f, &vfsflags.Opt),
|
||||
}
|
||||
|
||||
handler := &webdav.Handler{
|
||||
FileSystem: w,
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
Logger: w.logRequest, // FIXME
|
||||
}
|
||||
|
||||
w.srv = httplib.NewServer(handler, opt)
|
||||
return w
|
||||
}
|
||||
|
||||
// serve runs the http server - doesn't return
|
||||
func (w *WebDAV) serve() {
|
||||
err := w.srv.Serve()
|
||||
if err != nil {
|
||||
fs.Errorf(w.f, "Opening listener: %v", err)
|
||||
}
|
||||
fs.Logf(w.f, "WebDav Server started on %s", w.srv.URL())
|
||||
w.srv.Wait()
|
||||
}
|
||||
|
||||
// logRequest is called by the webdav module on every request
|
||||
func (w *WebDAV) logRequest(r *http.Request, err error) {
|
||||
fs.Infof(r.URL.Path, "%s from %s", r.Method, r.RemoteAddr)
|
||||
}
|
||||
|
||||
// Mkdir creates a directory
|
||||
func (w *WebDAV) Mkdir(ctx context.Context, name string, perm os.FileMode) (err error) {
|
||||
defer log.Trace(name, "perm=%v", perm)("err = %v", &err)
|
||||
dir, leaf, err := w.vfs.StatParent(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = dir.Mkdir(leaf)
|
||||
return err
|
||||
}
|
||||
|
||||
// OpenFile opens a file or a directory
|
||||
func (w *WebDAV) OpenFile(ctx context.Context, name string, flags int, perm os.FileMode) (file webdav.File, err error) {
|
||||
defer log.Trace(name, "flags=%v, perm=%v", flags, perm)("err = %v", &err)
|
||||
f, err := w.vfs.OpenFile(name, flags, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Handle{f}, nil
|
||||
}
|
||||
|
||||
// RemoveAll removes a file or a directory and its contents
|
||||
func (w *WebDAV) RemoveAll(ctx context.Context, name string) (err error) {
|
||||
defer log.Trace(name, "")("err = %v", &err)
|
||||
node, err := w.vfs.Stat(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = node.RemoveAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename a file or a directory
|
||||
func (w *WebDAV) Rename(ctx context.Context, oldName, newName string) (err error) {
|
||||
defer log.Trace(oldName, "newName=%q", newName)("err = %v", &err)
|
||||
return w.vfs.Rename(oldName, newName)
|
||||
}
|
||||
|
||||
// Stat returns info about the file or directory
|
||||
func (w *WebDAV) Stat(ctx context.Context, name string) (fi os.FileInfo, err error) {
|
||||
defer log.Trace(name, "")("fi=%+v, err = %v", &fi, &err)
|
||||
fi, err = w.vfs.Stat(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FileInfo{fi}, nil
|
||||
}
|
||||
|
||||
// Handle represents an open file
|
||||
type Handle struct {
|
||||
vfs.Handle
|
||||
}
|
||||
|
||||
// Readdir reads directory entries from the handle
|
||||
func (h Handle) Readdir(count int) (fis []os.FileInfo, err error) {
|
||||
fis, err = h.Handle.Readdir(count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Wrap each FileInfo
|
||||
for i := range fis {
|
||||
fis[i] = FileInfo{fis[i]}
|
||||
}
|
||||
return fis, nil
|
||||
}
|
||||
|
||||
// Stat the handle
|
||||
func (h Handle) Stat() (fi os.FileInfo, err error) {
|
||||
fi, err = h.Handle.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return FileInfo{fi}, nil
|
||||
}
|
||||
|
||||
// FileInfo represents info about a file satisfying os.FileInfo and
|
||||
// also some additional interfaces for webdav for ETag and ContentType
|
||||
type FileInfo struct {
|
||||
os.FileInfo
|
||||
}
|
||||
|
||||
// ETag returns an ETag for the FileInfo
|
||||
func (fi FileInfo) ETag(ctx context.Context) (etag string, err error) {
|
||||
defer log.Trace(fi, "")("etag=%q, err=%v", &etag, &err)
|
||||
if hashType == hash.None {
|
||||
return "", webdav.ErrNotImplemented
|
||||
}
|
||||
node, ok := (fi.FileInfo).(vfs.Node)
|
||||
if !ok {
|
||||
fs.Errorf(fi, "Expecting vfs.Node, got %T", fi.FileInfo)
|
||||
return "", webdav.ErrNotImplemented
|
||||
}
|
||||
entry := node.DirEntry()
|
||||
o, ok := entry.(fs.Object)
|
||||
if !ok {
|
||||
return "", webdav.ErrNotImplemented
|
||||
}
|
||||
hash, err := o.Hash(hashType)
|
||||
if err != nil || hash == "" {
|
||||
return "", webdav.ErrNotImplemented
|
||||
}
|
||||
return `"` + hash + `"`, nil
|
||||
}
|
||||
|
||||
// ContentType returns a content type for the FileInfo
|
||||
func (fi FileInfo) ContentType(ctx context.Context) (contentType string, err error) {
|
||||
defer log.Trace(fi, "")("etag=%q, err=%v", &contentType, &err)
|
||||
node, ok := (fi.FileInfo).(vfs.Node)
|
||||
if !ok {
|
||||
fs.Errorf(fi, "Expecting vfs.Node, got %T", fi.FileInfo)
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
entry := node.DirEntry()
|
||||
switch x := entry.(type) {
|
||||
case fs.Object:
|
||||
return fs.MimeType(x), nil
|
||||
case fs.Directory:
|
||||
return "inode/directory", nil
|
||||
}
|
||||
fs.Errorf(fi, "Expecting fs.Object or fs.Directory, got %T", entry)
|
||||
return "application/octet-stream", nil
|
||||
}
|
||||
78
.rclone_repo/cmd/serve/webdav/webdav_test.go
Executable file
78
.rclone_repo/cmd/serve/webdav/webdav_test.go
Executable file
@@ -0,0 +1,78 @@
|
||||
// Serve webdav tests set up a server and run the integration tests
|
||||
// for the webdav remote against it.
|
||||
//
|
||||
// We skip tests on platforms with troublesome character mappings
|
||||
|
||||
//+build !windows,!darwin
|
||||
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
_ "github.com/ncw/rclone/backend/local"
|
||||
"github.com/ncw/rclone/cmd/serve/httplib"
|
||||
"github.com/ncw/rclone/fstest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
const (
|
||||
testBindAddress = "localhost:51778"
|
||||
testURL = "http://" + testBindAddress + "/"
|
||||
)
|
||||
|
||||
// check interfaces
|
||||
var (
|
||||
_ os.FileInfo = FileInfo{nil}
|
||||
_ webdav.ETager = FileInfo{nil}
|
||||
_ webdav.ContentTyper = FileInfo{nil}
|
||||
)
|
||||
|
||||
// TestWebDav runs the webdav server then runs the unit tests for the
|
||||
// webdav remote against it.
|
||||
func TestWebDav(t *testing.T) {
|
||||
opt := httplib.DefaultOpt
|
||||
opt.ListenAddr = testBindAddress
|
||||
|
||||
fstest.Initialise()
|
||||
|
||||
fremote, _, clean, err := fstest.RandomRemote(*fstest.RemoteName, *fstest.SubDir)
|
||||
assert.NoError(t, err)
|
||||
defer clean()
|
||||
|
||||
err = fremote.Mkdir("")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Start the server
|
||||
w := newWebDAV(fremote, &opt)
|
||||
go w.serve()
|
||||
defer w.srv.Close()
|
||||
|
||||
// Change directory to run the tests
|
||||
err = os.Chdir("../../../backend/webdav")
|
||||
assert.NoError(t, err, "failed to cd to webdav remote")
|
||||
|
||||
// Run the webdav tests with an on the fly remote
|
||||
args := []string{"test"}
|
||||
if testing.Verbose() {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
if *fstest.Verbose {
|
||||
args = append(args, "-verbose")
|
||||
}
|
||||
args = append(args, "-remote", "webdavtest:")
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"RCLONE_CONFIG_WEBDAVTEST_TYPE=webdav",
|
||||
"RCLONE_CONFIG_WEBDAVTEST_URL="+testURL,
|
||||
"RCLONE_CONFIG_WEBDAVTEST_VENDOR=other",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) != 0 {
|
||||
t.Logf("\n----------\n%s----------\n", string(out))
|
||||
}
|
||||
assert.NoError(t, err, "Running webdav integration tests")
|
||||
}
|
||||
Reference in New Issue
Block a user