mirror of
https://github.com/rclone/rclone.git
synced 2026-05-12 01:57:56 -04:00
gui: allow serving from a local zip file or an unpacked directory
This helps with local development and allows users to try older and newer releases of rclone-web.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"embed"
|
||||
"flag"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rclone/rclone/cmd"
|
||||
@@ -49,17 +51,29 @@ func init() {
|
||||
}
|
||||
|
||||
var commandDefinition = &cobra.Command{
|
||||
Use: "gui",
|
||||
Use: "gui [path]",
|
||||
Short: `Open the web based GUI.`,
|
||||
Long: `This command starts an embedded web GUI for rclone and opens it in
|
||||
your default browser.
|
||||
|
||||
It starts an RC API server and a GUI server on separate localhost
|
||||
This starts an RC API server and a GUI server on separate localhost
|
||||
ports, generates login credentials automatically unless --no-auth
|
||||
is specified, and opens the browser already authenticated.
|
||||
|
||||
rclone gui
|
||||
|
||||
By default rclone gui serves the web GUI that was embedded into the
|
||||
rclone binary at build time from https://github.com/rclone/rclone-web/
|
||||
You can override this by passing a path to either an unpacked GUI
|
||||
directory or a dist.zip archive (e.g. one downloaded from the
|
||||
rclone-web releases page):
|
||||
|
||||
rclone gui ./my-dist/
|
||||
rclone gui ./dist.zip
|
||||
|
||||
This is useful for iterating on the GUI locally without rebuilding
|
||||
rclone, or for serving a different GUI release than the one embedded.
|
||||
|
||||
Use --no-open-browser to skip opening the browser automatically:
|
||||
|
||||
rclone gui --no-open-browser
|
||||
@@ -81,9 +95,21 @@ Use --no-auth to disable authentication entirely:
|
||||
"groups": "RC",
|
||||
},
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
cmd.CheckArgs(0, 0, command, args)
|
||||
cmd.CheckArgs(0, 1, command, args)
|
||||
ctx := context.Background()
|
||||
|
||||
// Resolve the GUI source (embedded, directory, or .zip)
|
||||
// before binding any sockets so errors surface immediately.
|
||||
var srcPath string
|
||||
if len(args) == 1 {
|
||||
srcPath = args[0]
|
||||
}
|
||||
srcFS, cleanupSrc, err := guiSourceFS(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = cleanupSrc() }()
|
||||
|
||||
// Create the GUI server (binds port eagerly, before Serve)
|
||||
guiCfg := libhttp.DefaultCfg()
|
||||
if command.Flags().Changed("addr") {
|
||||
@@ -159,8 +185,8 @@ Use --no-auth to disable authentication entirely:
|
||||
// does not expose URLs, and we know the address we passed in).
|
||||
rcURL := "http://" + opt.HTTP.ListenAddr[0] + "/"
|
||||
|
||||
// Mount the embedded GUI handler and start serving
|
||||
spaHandler, err := guiHandler()
|
||||
// Mount the GUI handler and start serving
|
||||
spaHandler, err := guiHandler(srcFS)
|
||||
if err != nil || spaHandler == nil {
|
||||
return fmt.Errorf("failed to start GUI handler: %w", err)
|
||||
}
|
||||
@@ -213,24 +239,53 @@ func originFromURL(rawURL string) string {
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// guiHandler returns an http.Handler that serves the embedded GUI bundle
|
||||
// with SPA fallback: paths that don't match a real file return index.html.
|
||||
func guiHandler() (http.Handler, error) {
|
||||
sub, err := iofs.Sub(assets, "dist")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedded GUI dir not found: was `make fetch-gui` run before building?: %w", err)
|
||||
// guiSourceFS opens the GUI bundle at the given path. An empty path
|
||||
// returns the embedded bundle. The returned cleanup func must be
|
||||
// called on shutdown (no-op for embedded/DirFS, Close for the zip
|
||||
// reader).
|
||||
func guiSourceFS(path string) (iofs.FS, func() error, error) {
|
||||
noop := func() error { return nil }
|
||||
if path == "" {
|
||||
sub, err := iofs.Sub(assets, "dist")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("embedded GUI dir not found: was `make fetch-gui` run before building?: %w", err)
|
||||
}
|
||||
if _, err := iofs.Stat(sub, "index.html"); err != nil {
|
||||
return nil, nil, fmt.Errorf("embedded GUI not found: was `make fetch-gui` run before building?: %w", err)
|
||||
}
|
||||
return sub, noop, nil
|
||||
}
|
||||
_, err = iofs.Stat(sub, "index.html")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedded GUI not found: was `make fetch-gui` run before building?: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to stat GUI source %q: %w", path, err)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
if info.IsDir() {
|
||||
return os.DirFS(path), noop, nil
|
||||
}
|
||||
if info.Mode().IsRegular() && strings.HasSuffix(strings.ToLower(path), ".zip") {
|
||||
zr, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to open GUI zip %q: %w", path, err)
|
||||
}
|
||||
return zr, zr.Close, nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("GUI source must be a directory or a .zip file: %q", path)
|
||||
}
|
||||
|
||||
// guiHandler returns an http.Handler that serves the GUI bundle from
|
||||
// srcFS with SPA fallback: paths that don't match a real file return
|
||||
// index.html.
|
||||
func guiHandler(srcFS iofs.FS) (http.Handler, error) {
|
||||
if _, err := iofs.Stat(srcFS, "index.html"); err != nil {
|
||||
return nil, fmt.Errorf("GUI bundle has no index.html: %w", err)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(srcFS))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if _, err := iofs.Stat(sub, path); err == nil {
|
||||
if _, err := iofs.Stat(srcFS, path); err == nil {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
iofs "io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -11,6 +15,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testIndexHTML = `<!doctype html><html><body><div id="root"></div></body></html>`
|
||||
testIconSVG = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`
|
||||
)
|
||||
|
||||
func TestBuildLoginURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -94,17 +103,153 @@ func TestFreePort(t *testing.T) {
|
||||
assert.Less(t, port, 65536)
|
||||
}
|
||||
|
||||
// newTestHandler returns a guiHandler, or skips the test if the embedded
|
||||
// GUI bundle is not present (i.e. `make fetch-gui` has not been run).
|
||||
// newTestHandler returns a guiHandler backed by the embedded GUI
|
||||
// bundle, or skips the test if it is not present (i.e. `make fetch-gui`
|
||||
// has not been run).
|
||||
func newTestHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
h, err := guiHandler()
|
||||
srcFS, cleanup, err := guiSourceFS("")
|
||||
if err != nil {
|
||||
t.Skipf("skipping: GUI dist not embedded (run `make fetch-gui`): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
h, err := guiHandler(srcFS)
|
||||
require.NoError(t, err)
|
||||
return h
|
||||
}
|
||||
|
||||
// writeTestDir creates a temp directory containing a fake GUI bundle
|
||||
// (index.html + icon.svg) and returns its path.
|
||||
func writeTestDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte(testIndexHTML), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "icon.svg"), []byte(testIconSVG), 0644))
|
||||
return dir
|
||||
}
|
||||
|
||||
// writeTestZip creates a temp .zip file containing a fake GUI bundle
|
||||
// and returns its path.
|
||||
func writeTestZip(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "dist.zip")
|
||||
f, err := os.Create(path)
|
||||
require.NoError(t, err)
|
||||
zw := zip.NewWriter(f)
|
||||
for name, content := range map[string]string{
|
||||
"index.html": testIndexHTML,
|
||||
"icon.svg": testIconSVG,
|
||||
} {
|
||||
w, err := zw.Create(name)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, zw.Close())
|
||||
require.NoError(t, f.Close())
|
||||
return path
|
||||
}
|
||||
|
||||
func TestGuiSourceFS(t *testing.T) {
|
||||
t.Run("empty path returns embedded", func(t *testing.T) {
|
||||
srcFS, cleanup, err := guiSourceFS("")
|
||||
if err != nil {
|
||||
t.Skipf("skipping: GUI dist not embedded (run `make fetch-gui`): %v", err)
|
||||
}
|
||||
defer func() { _ = cleanup() }()
|
||||
_, err = iofs.Stat(srcFS, "index.html")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("directory path", func(t *testing.T) {
|
||||
dir := writeTestDir(t)
|
||||
srcFS, cleanup, err := guiSourceFS(dir)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = cleanup() }()
|
||||
data, err := iofs.ReadFile(srcFS, "index.html")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testIndexHTML, string(data))
|
||||
})
|
||||
|
||||
t.Run("zip path", func(t *testing.T) {
|
||||
path := writeTestZip(t)
|
||||
srcFS, cleanup, err := guiSourceFS(path)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = cleanup() }()
|
||||
data, err := iofs.ReadFile(srcFS, "index.html")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testIndexHTML, string(data))
|
||||
})
|
||||
|
||||
t.Run("nonexistent path", func(t *testing.T) {
|
||||
_, _, err := guiSourceFS(filepath.Join(t.TempDir(), "nope"))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("regular file without zip suffix", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "notazip.txt")
|
||||
require.NoError(t, os.WriteFile(path, []byte("hi"), 0644))
|
||||
_, _, err := guiSourceFS(path)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "directory or a .zip file")
|
||||
})
|
||||
}
|
||||
|
||||
// handlerForSource builds a handler from a temp directory or zip
|
||||
// source. Used by parameterised tests that need to verify the handler
|
||||
// works against all FS implementations, not just the embedded one.
|
||||
func handlerForSource(t *testing.T, srcPath string) http.Handler {
|
||||
t.Helper()
|
||||
srcFS, cleanup, err := guiSourceFS(srcPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
h, err := guiHandler(srcFS)
|
||||
require.NoError(t, err)
|
||||
return h
|
||||
}
|
||||
|
||||
func TestHandlerAllSources(t *testing.T) {
|
||||
dir := writeTestDir(t)
|
||||
zipPath := writeTestZip(t)
|
||||
sources := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"directory", dir},
|
||||
{"zip", zipPath},
|
||||
}
|
||||
for _, src := range sources {
|
||||
t.Run(src.name, func(t *testing.T) {
|
||||
h := handlerForSource(t, src.path)
|
||||
|
||||
// index.html
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Result().StatusCode)
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
assert.Contains(t, string(body), `<div id="root"></div>`)
|
||||
|
||||
// static asset
|
||||
req = httptest.NewRequest("GET", "/icon.svg", nil)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Result().StatusCode)
|
||||
body, _ = io.ReadAll(w.Result().Body)
|
||||
assert.Contains(t, string(body), "<svg")
|
||||
|
||||
// SPA fallback
|
||||
req = httptest.NewRequest("GET", "/login", nil)
|
||||
w = httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Result().StatusCode)
|
||||
body, _ = io.ReadAll(w.Result().Body)
|
||||
assert.Contains(t, string(body), `<div id="root"></div>`,
|
||||
"SPA fallback should serve index.html for unknown routes")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerServesIndexHTML(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
Reference in New Issue
Block a user