diff --git a/cmd/rcd/rcd.go b/cmd/rcd/rcd.go index 771f19b5c..93a971253 100644 --- a/cmd/rcd/rcd.go +++ b/cmd/rcd/rcd.go @@ -1,19 +1,30 @@ package rcd import ( + "archive/zip" + "encoding/json" + "io" "log" + "net/http" + "os" + "path/filepath" + "strconv" + "time" "github.com/rclone/rclone/cmd" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config" "github.com/rclone/rclone/fs/rc/rcflags" "github.com/rclone/rclone/fs/rc/rcserver" + "github.com/rclone/rclone/lib/errors" "github.com/spf13/cobra" ) func init() { - cmd.Root.AddCommand(commandDefintion) + cmd.Root.AddCommand(commandDefinition) } -var commandDefintion = &cobra.Command{ +var commandDefinition = &cobra.Command{ Use: "rcd *", Short: `Run rclone listening to remote control commands only.`, Long: ` @@ -32,11 +43,19 @@ See the [rc documentation](/rc/) for more info on the rc flags. if rcflags.Opt.Enabled { log.Fatalf("Don't supply --rc flag when using rcd") } + // Start the rc rcflags.Opt.Enabled = true if len(args) > 0 { rcflags.Opt.Files = args[0] } + + if rcflags.Opt.WebUI { + if err := checkRelease(rcflags.Opt.WebGUIUpdate); err != nil { + log.Fatalf("Error while fetching the latest release of rclone-webui-react %v", err) + } + } + s, err := rcserver.Start(&rcflags.Opt) if err != nil { log.Fatalf("Failed to start remote control: %v", err) @@ -44,6 +63,186 @@ See the [rc documentation](/rc/) for more info on the rc flags. if s == nil { log.Fatal("rc server not configured") } + s.Wait() }, } + +//checkRelease is a helper function to download and setup latest release of rclone-webui-react +func checkRelease(shouldUpdate bool) (err error) { + // Get the latest release details + WebUIURL, tag, size, err := getLatestReleaseURL() + if err != nil { + return err + } + + zipName := tag + ".zip" + cachePath := filepath.Join(config.CacheDir, "webgui") + zipPath := filepath.Join(cachePath, zipName) + extractPath := filepath.Join(cachePath, "current") + + if !exists(cachePath) { + if err := os.MkdirAll(cachePath, 755); err != nil { + fs.Logf(nil, "Error creating cache directory: %s", cachePath) + } + } + // Load the file + exists := exists(zipPath) + // if the zipFile does not exist or forced update is enforced. + if !exists || shouldUpdate { + fs.Logf(nil, "A new release for gui is present at "+WebUIURL) + fs.Logf(nil, "Downloading webgui binary. Please wait. [Size: %s, Path : %s]\n", strconv.Itoa(size), zipPath) + err := downloadFile(zipPath, WebUIURL) + if err != nil { + return err + } + err = os.RemoveAll(extractPath) + if err != nil { + fs.Logf(nil, "No previous downloads to remove") + } + fs.Logf(nil, "Unzipping") + err = unzip(zipPath, extractPath) + if err != nil { + return err + } + + } else { + fs.Logf(nil, "Required files exist. Skipping download") + } + return nil +} + +// getLatestReleaseURL returns the latest release details of the rclone-webui-react +func getLatestReleaseURL() (string, string, int, error) { + resp, err := http.Get(rcflags.Opt.WebGUIFetchURL) + if err != nil { + return "", "", 0, errors.New("Error getting latest release of rclone-webui") + } + results := gitHubRequest{} + if err := json.NewDecoder(resp.Body).Decode(&results); err != nil { + return "", "", 0, errors.New("Could not decode results from http request") + } + + res := results.Assets[0].BrowserDownloadURL + tag := results.TagName + size := results.Assets[0].Size + //fmt.Println( "URL:" + res) + + return res, tag, size, nil + +} + +// downloadFile is a helper function to download a file from url to the filepath +func downloadFile(filepath string, url string) error { + + // Get the data + resp, err := http.Get(url) + if err != nil { + return err + } + defer fs.CheckClose(resp.Body, &err) + + // Create the file + out, err := os.Create(filepath) + if err != nil { + return err + } + defer fs.CheckClose(out, &err) + + // Write the body to file + _, err = io.Copy(out, resp.Body) + return err +} + +// unzip is a helper function to unzip a file specified in src to path dest +func unzip(src, dest string) (err error) { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer fs.CheckClose(r, &err) + + if err := os.MkdirAll(dest, 0755); err != nil { + return err + } + + // Closure to address file descriptors issue with all the deferred .Close() methods + extractAndWriteFile := func(f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer fs.CheckClose(rc, &err) + + path := filepath.Join(dest, f.Name) + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(path, f.Mode()); err != nil { + return err + } + } else { + if err := os.MkdirAll(filepath.Dir(path), f.Mode()); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + defer fs.CheckClose(f, &err) + + _, err = io.Copy(f, rc) + if err != nil { + return err + } + } + return nil + } + + for _, f := range r.File { + err := extractAndWriteFile(f) + if err != nil { + return err + } + } + + return nil +} + +// exists returns whether the given file or directory exists +func exists(path string) bool { + _, err := os.Stat(path) + if err == nil { + return true + } + if os.IsNotExist(err) { + return false + } + return true +} + +// gitHubRequest Maps the GitHub API request to structure +type gitHubRequest struct { + URL string `json:"url"` + + Prerelease bool `json:"prerelease"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + TagName string `json:"tag_name"` + Assets []struct { + URL string `json:"url"` + ID int `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Label string `json:"label"` + ContentType string `json:"content_type"` + State string `json:"state"` + Size int `json:"size"` + DownloadCount int `json:"download_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + TarballURL string `json:"tarball_url"` + ZipballURL string `json:"zipball_url"` + Body string `json:"body"` +} diff --git a/fs/rc/rc.go b/fs/rc/rc.go index 4c803e28c..b09c861c7 100644 --- a/fs/rc/rc.go +++ b/fs/rc/rc.go @@ -17,11 +17,15 @@ import ( // Options contains options for the remote control server type Options struct { - HTTPOptions httplib.Options - Enabled bool // set to enable the server - Serve bool // set to serve files from remotes - Files string // set to enable serving files locally - NoAuth bool // set to disable auth checks on AuthRequired methods + HTTPOptions httplib.Options + Enabled bool // set to enable the server + Serve bool // set to serve files from remotes + Files string // set to enable serving files locally + NoAuth bool // set to disable auth checks on AuthRequired methods + WebUI bool // set to launch the web ui + WebGUIUpdate bool // set to download new update + WebGUIFetchURL string // set the default url for fetching webgui + } // DefaultOpt is the default values used for Options diff --git a/fs/rc/rcflags/rcflags.go b/fs/rc/rcflags/rcflags.go index 072bf728b..96cd03b1b 100644 --- a/fs/rc/rcflags/rcflags.go +++ b/fs/rc/rcflags/rcflags.go @@ -20,5 +20,8 @@ func AddFlags(flagSet *pflag.FlagSet) { 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.") flags.BoolVarP(flagSet, &Opt.NoAuth, "rc-no-auth", "", false, "Don't require auth for certain methods.") + flags.BoolVarP(flagSet, &Opt.WebUI, "rc-web-gui", "w", false, "Launch WebGUI on localhost") + flags.BoolVarP(flagSet, &Opt.WebGUIUpdate, "rc-web-gui-update", "", false, "Update / Force update to latest version of web gui") + flags.StringVarP(flagSet, &Opt.WebGUIFetchURL, "rc-web-fetch-url", "", "https://api.github.com/repos/negative0/rclone-webui-react/releases/latest", "URL to fetch the releases from") httpflags.AddFlagsPrefix(flagSet, "rc-", &Opt.HTTPOptions) } diff --git a/fs/rc/rcserver/rcserver.go b/fs/rc/rcserver/rcserver.go index f9f845927..53cec2088 100644 --- a/fs/rc/rcserver/rcserver.go +++ b/fs/rc/rcserver/rcserver.go @@ -8,6 +8,7 @@ import ( "mime" "net/http" "net/url" + "path/filepath" "regexp" "sort" "strings" @@ -56,10 +57,17 @@ func newServer(opt *rc.Options, mux *http.ServeMux) *Server { _ = mime.AddExtensionType(".wasm", "application/wasm") _ = mime.AddExtensionType(".js", "application/javascript") + cachePath := filepath.Join(config.CacheDir, "webgui") + extractPath := filepath.Join(cachePath, "current/build") // File handling if opt.Files != "" { + if opt.WebUI { + fs.Logf(nil, "--rc-files overrides --rc-web-gui command\n") + } fs.Logf(nil, "Serving files from %q", opt.Files) s.files = http.FileServer(http.Dir(opt.Files)) + } else if opt.WebUI { + s.files = http.FileServer(http.Dir(extractPath)) } return s } @@ -123,8 +131,9 @@ func (s *Server) handler(w http.ResponseWriter, r *http.Request) { w.Header().Add("Access-Control-Allow-Origin", "*") // echo back access control headers client needs - reqAccessHeaders := r.Header.Get("Access-Control-Request-Headers") - w.Header().Add("Access-Control-Allow-Headers", reqAccessHeaders) + //reqAccessHeaders := r.Header.Get("Access-Control-Request-Headers") + w.Header().Add("Access-Control-Request-Method", "POST, OPTIONS, GET, HEAD") + w.Header().Add("Access-Control-Allow-Headers", "authorization, Content-Type") switch r.Method { case "POST": diff --git a/fs/rc/rcserver/rcserver_test.go b/fs/rc/rcserver/rcserver_test.go index c06ee567f..648247104 100644 --- a/fs/rc/rcserver/rcserver_test.go +++ b/fs/rc/rcserver/rcserver_test.go @@ -458,8 +458,9 @@ func TestMethods(t *testing.T) { Status: http.StatusOK, Expected: "", Headers: map[string]string{ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "", + "Access-Control-Allow-Origin": "*", + "Access-Control-Request-Method": "POST, OPTIONS, GET, HEAD", + "Access-Control-Allow-Headers": "authorization, Content-Type", }, }, { Name: "bad",