From f56ad31d41b3aefff339eef02a98b8c80a528e9e Mon Sep 17 00:00:00 2001 From: Jarek Kowalski Date: Thu, 23 Dec 2021 12:09:55 -0800 Subject: [PATCH] ui: apply dark mode default and persist user choice (#1621) --- cli/command_server_start.go | 9 +++++ go.mod | 2 +- go.sum | 2 + internal/server/api_ui_pref.go | 63 +++++++++++++++++++++++++++++ internal/server/api_ui_pref_test.go | 35 ++++++++++++++++ internal/server/server.go | 3 ++ internal/server/server_test.go | 6 ++- internal/serverapi/serverapi.go | 5 +++ 8 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 internal/server/api_ui_pref.go create mode 100644 internal/server/api_ui_pref_test.go diff --git a/cli/command_server_start.go b/cli/command_server_start.go index b28bb6dda..52d9c63ca 100644 --- a/cli/command_server_start.go +++ b/cli/command_server_start.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "os" + "path/filepath" "strings" "time" @@ -58,6 +59,7 @@ type commandServerStart struct { serverStartTLSGenerateCertNames []string serverStartTLSPrintFullServerCert bool uiTitlePrefix string + uiPreferencesFile string logServerRequests bool @@ -95,6 +97,7 @@ func (c *commandServerStart) setup(svc advancedAppServices, parent commandParent cmd.Flag("tls-print-server-cert", "Print server certificate").Hidden().BoolVar(&c.serverStartTLSPrintFullServerCert) cmd.Flag("ui-title-prefix", "UI title prefix").Hidden().Envar("KOPIA_UI_TITLE_PREFIX").StringVar(&c.uiTitlePrefix) + cmd.Flag("ui-preferences-file", "Path to JSON file storing UI preferences").StringVar(&c.uiPreferencesFile) cmd.Flag("log-server-requests", "Log server requests").Hidden().BoolVar(&c.logServerRequests) @@ -115,6 +118,11 @@ func (c *commandServerStart) run(ctx context.Context, rep repo.Repository) error return errors.Wrap(err, "unable to initialize authentication") } + uiPreferencesFile := c.uiPreferencesFile + if uiPreferencesFile == "" { + uiPreferencesFile = filepath.Join(filepath.Dir(c.svc.repositoryConfigFileName()), "ui-preferences.json") + } + srv, err := server.New(ctx, server.Options{ ConfigFile: c.svc.repositoryConfigFileName(), ConnectOptions: c.co.toRepoConnectOptions(), @@ -126,6 +134,7 @@ func (c *commandServerStart) run(ctx context.Context, rep repo.Repository) error UIUser: c.sf.serverUsername, LogRequests: c.logServerRequests, PasswordPersist: c.svc.passwordPersistenceStrategy(), + UIPreferencesFile: uiPreferencesFile, }) if err != nil { return errors.Wrap(err, "unable to initialize server") diff --git a/go.mod b/go.mod index 2b07e20ef..12bd5074f 100644 --- a/go.mod +++ b/go.mod @@ -93,7 +93,7 @@ require ( github.com/googleapis/gax-go/v2 v2.1.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kopia/htmluibuild v0.0.0-20211223073627-e6b6141bba94 + github.com/kopia/htmluibuild v0.0.0-20211223182507-6b6a50b2f0d1 github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-ieproxy v0.0.1 // indirect github.com/mattn/go-isatty v0.0.14 // indirect diff --git a/go.sum b/go.sum index 063fc2c2b..e7f2593ff 100644 --- a/go.sum +++ b/go.sum @@ -391,6 +391,8 @@ github.com/kopia/htmluibuild v0.0.0-20211218222024-ddc4c4aa34ee h1:72jpX5y1qR8SH github.com/kopia/htmluibuild v0.0.0-20211218222024-ddc4c4aa34ee/go.mod h1:eWer4rx9P8lJo2eKc+Q7AZ1dE1x1hJNdkbDFPzMu1Hw= github.com/kopia/htmluibuild v0.0.0-20211223073627-e6b6141bba94 h1:cetYBxizQNLfwF8MKxuwra/mOYwuBmC3Asy+hVAl9wA= github.com/kopia/htmluibuild v0.0.0-20211223073627-e6b6141bba94/go.mod h1:eWer4rx9P8lJo2eKc+Q7AZ1dE1x1hJNdkbDFPzMu1Hw= +github.com/kopia/htmluibuild v0.0.0-20211223182507-6b6a50b2f0d1 h1:stwETPrkIzdLAyQ+yzD1pfl/YHVRoy1kFDJbcyCSfSE= +github.com/kopia/htmluibuild v0.0.0-20211223182507-6b6a50b2f0d1/go.mod h1:eWer4rx9P8lJo2eKc+Q7AZ1dE1x1hJNdkbDFPzMu1Hw= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= diff --git a/internal/server/api_ui_pref.go b/internal/server/api_ui_pref.go new file mode 100644 index 000000000..13797bac0 --- /dev/null +++ b/internal/server/api_ui_pref.go @@ -0,0 +1,63 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + + "github.com/natefinch/atomic" + "github.com/pkg/errors" + + "github.com/kopia/kopia/internal/serverapi" +) + +func (s *Server) getUIPreferencesOrEmpty() (serverapi.UIPreferences, error) { + p := serverapi.UIPreferences{} + + if s.options.UIPreferencesFile == "" { + return p, nil + } + + f, err := os.Open(s.options.UIPreferencesFile) + if os.IsNotExist(err) { + return p, nil + } + + if err != nil { + return p, errors.Wrap(err, "unable to open UI preferences file") + } + + defer f.Close() //nolint:errcheck,gosec + + if err := json.NewDecoder(f).Decode(&p); err != nil { + return p, errors.Wrap(err, "invalid UI preferences file") + } + + return p, nil +} + +func (s *Server) handleGetUIPreferences(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError) { + p, err := s.getUIPreferencesOrEmpty() + if err != nil { + return nil, internalServerError(err) + } + + return &p, nil +} + +func (s *Server) handleSetUIPreferences(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError) { + var p serverapi.UIPreferences + + // verify the JSON is valid by unmarshaling it + if err := json.Unmarshal(body, &p); err != nil { + return nil, requestError(serverapi.ErrorMalformedRequest, "malformed request") + } + + if err := atomic.WriteFile(s.options.UIPreferencesFile, bytes.NewReader(body)); err != nil { + return nil, internalServerError(err) + } + + return serverapi.Empty{}, nil +} diff --git a/internal/server/api_ui_pref_test.go b/internal/server/api_ui_pref_test.go new file mode 100644 index 000000000..8c8890519 --- /dev/null +++ b/internal/server/api_ui_pref_test.go @@ -0,0 +1,35 @@ +package server_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kopia/kopia/internal/apiclient" + "github.com/kopia/kopia/internal/repotesting" + "github.com/kopia/kopia/internal/serverapi" +) + +func TestUIPreferences(t *testing.T) { + ctx, env := repotesting.NewEnvironment(t, repotesting.FormatNotImportant) + srvInfo := startServer(t, env, false) + + cli, err := apiclient.NewKopiaAPIClient(apiclient.Options{ + BaseURL: srvInfo.BaseURL, + TrustedServerCertificateFingerprint: srvInfo.TrustedServerCertificateFingerprint, + Username: testUIUsername, + Password: testUIPassword, + }) + + require.NoError(t, err) + + var p, p2 serverapi.UIPreferences + + require.NoError(t, cli.Get(ctx, "ui-preferences", nil, &p)) + require.Equal(t, "", p.Theme) + p.Theme = "dark" + + require.NoError(t, cli.Put(ctx, "ui-preferences", &p, &serverapi.Empty{})) + require.NoError(t, cli.Get(ctx, "ui-preferences", nil, &p2)) + require.Equal(t, p, p2) +} diff --git a/internal/server/server.go b/internal/server/server.go index 73f55770b..937131da7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -136,6 +136,8 @@ func (s *Server) APIHandlers(legacyAPI bool) http.Handler { m.HandleFunc("/api/v1/mounts", s.handleAPI(requireUIUser, s.handleMountList)).Methods(http.MethodGet) m.HandleFunc("/api/v1/current-user", s.handleAPIPossiblyNotConnected(requireUIUser, s.handleCurrentUser)).Methods(http.MethodGet) + m.HandleFunc("/api/v1/ui-preferences", s.handleAPIPossiblyNotConnected(requireUIUser, s.handleGetUIPreferences)).Methods(http.MethodGet) + m.HandleFunc("/api/v1/ui-preferences", s.handleAPIPossiblyNotConnected(requireUIUser, s.handleSetUIPreferences)).Methods(http.MethodPut) m.HandleFunc("/api/v1/tasks-summary", s.handleAPI(requireUIUser, s.handleTaskSummary)).Methods(http.MethodGet) m.HandleFunc("/api/v1/tasks", s.handleAPI(requireUIUser, s.handleTaskList)).Methods(http.MethodGet) @@ -669,6 +671,7 @@ type Options struct { AuthCookieSigningKey string LogRequests bool UIUser string // name of the user allowed to access the UI + UIPreferencesFile string // name of the JSON file storing UI preferences } // New creates a Server. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index dd047c6b5..9cd82c7c4 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "path/filepath" "testing" "time" @@ -52,8 +53,9 @@ func startServer(t *testing.T, env *repotesting.Environment, tls bool) *repo.API auth.AuthenticateSingleUser(testUsername+"@"+testHostname, testPassword), auth.AuthenticateSingleUser(testUIUsername, testUIPassword), ), - RefreshInterval: 1 * time.Minute, - UIUser: testUIUsername, + RefreshInterval: 1 * time.Minute, + UIUser: testUIUsername, + UIPreferencesFile: filepath.Join(testutil.TempDirectory(t), "ui-pref.json"), }) require.NoError(t, err) diff --git a/internal/serverapi/serverapi.go b/internal/serverapi/serverapi.go index 35154bacf..0681588c2 100644 --- a/internal/serverapi/serverapi.go +++ b/internal/serverapi/serverapi.go @@ -251,3 +251,8 @@ type ResolvePathResponse struct { type CLIInfo struct { Executable string `json:"executable"` } + +// UIPreferences represents JSON object storing UI preferences. +type UIPreferences struct { + Theme string `json:"theme"` // 'dark', 'light' or '' +}