diff --git a/cmd/syncthing/gui.go b/cmd/syncthing/gui.go index 22fbaf5c5..6a5448dfb 100644 --- a/cmd/syncthing/gui.go +++ b/cmd/syncthing/gui.go @@ -7,13 +7,10 @@ package main import ( - "bytes" - "compress/gzip" "crypto/tls" "encoding/json" "fmt" "io/ioutil" - "mime" "net" "net/http" "os" @@ -26,7 +23,6 @@ import ( "time" "github.com/rcrowley/go-metrics" - "github.com/syncthing/syncthing/lib/auto" "github.com/syncthing/syncthing/lib/config" "github.com/syncthing/syncthing/lib/db" "github.com/syncthing/syncthing/lib/discover" @@ -54,8 +50,7 @@ type apiService struct { cfg configIntf httpsCertFile string httpsKeyFile string - assetDir string - themes []string + statics *staticsServer model modelIntf eventSub events.BufferedSubscription discoverer discover.CachingMux @@ -123,7 +118,7 @@ func newAPIService(id protocol.DeviceID, cfg configIntf, httpsCertFile, httpsKey cfg: cfg, httpsCertFile: httpsCertFile, httpsKeyFile: httpsKeyFile, - assetDir: assetDir, + statics: newStaticsServer(cfg.GUI().Theme, assetDir), model: m, eventSub: eventSub, discoverer: discoverer, @@ -135,25 +130,6 @@ func newAPIService(id protocol.DeviceID, cfg configIntf, httpsCertFile, httpsKey systemLog: systemLog, } - seen := make(map[string]struct{}) - // Load themes from compiled in assets. - for file := range auto.Assets() { - theme := strings.Split(file, "/")[0] - if _, ok := seen[theme]; !ok { - seen[theme] = struct{}{} - service.themes = append(service.themes, theme) - } - } - if assetDir != "" { - // Load any extra themes from the asset override dir. - for _, dir := range dirNames(assetDir) { - if _, ok := seen[dir]; !ok { - seen[dir] = struct{}{} - service.themes = append(service.themes, dir) - } - } - } - return service } @@ -305,20 +281,11 @@ func (s *apiService) Serve() { mux.HandleFunc("/qr/", s.getQR) // Serve compiled in assets unless an asset directory was set (for development) - assets := &embeddedStatic{ - theme: s.cfg.GUI().Theme, - lastModified: time.Now().Truncate(time.Second), // must truncate, for the wire precision is 1s - mut: sync.NewRWMutex(), - assetDir: s.assetDir, - assets: auto.Assets(), - } - mux.Handle("/", assets) + mux.Handle("/", s.statics) // Handle the special meta.js path mux.HandleFunc("/meta.js", s.getJSMetadata) - s.cfg.Subscribe(assets) - guiCfg := s.cfg.GUI() // Wrap everything in CSRF protection. The /rest prefix should be @@ -401,6 +368,10 @@ func (s *apiService) CommitConfiguration(from, to config.Configuration) bool { return true } + if to.GUI.Theme != from.GUI.Theme { + s.statics.setTheme(to.GUI.Theme) + } + // Tell the serve loop to restart s.configChanged <- struct{}{} @@ -842,7 +813,6 @@ func (s *apiService) getSystemStatus(w http.ResponseWriter, r *http.Request) { res["pathSeparator"] = string(filepath.Separator) res["uptime"] = int(time.Since(startTime).Seconds()) res["startTime"] = startTime - res["themes"] = s.themes sendJSON(w, res) } @@ -1192,136 +1162,6 @@ func (s *apiService) getSystemBrowse(w http.ResponseWriter, r *http.Request) { sendJSON(w, ret) } -type embeddedStatic struct { - theme string - lastModified time.Time - mut sync.RWMutex - assetDir string - assets map[string][]byte -} - -func (s embeddedStatic) ServeHTTP(w http.ResponseWriter, r *http.Request) { - file := r.URL.Path - - if file[0] == '/' { - file = file[1:] - } - - if len(file) == 0 { - file = "index.html" - } - - s.mut.RLock() - theme := s.theme - modified := s.lastModified - s.mut.RUnlock() - - // Check for an override for the current theme. - if s.assetDir != "" { - p := filepath.Join(s.assetDir, s.theme, filepath.FromSlash(file)) - if _, err := os.Stat(p); err == nil { - http.ServeFile(w, r, p) - return - } - } - - // Check for a compiled in asset for the current theme. - bs, ok := s.assets[theme+"/"+file] - if !ok { - // Check for an overridden default asset. - if s.assetDir != "" { - p := filepath.Join(s.assetDir, config.DefaultTheme, filepath.FromSlash(file)) - if _, err := os.Stat(p); err == nil { - http.ServeFile(w, r, p) - return - } - } - - // Check for a compiled in default asset. - bs, ok = s.assets[config.DefaultTheme+"/"+file] - if !ok { - http.NotFound(w, r) - return - } - } - - modifiedSince, err := http.ParseTime(r.Header.Get("If-Modified-Since")) - if err == nil && !modified.After(modifiedSince) { - w.WriteHeader(http.StatusNotModified) - return - } - - mtype := s.mimeTypeForFile(file) - if len(mtype) != 0 { - w.Header().Set("Content-Type", mtype) - } - if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { - w.Header().Set("Content-Encoding", "gzip") - } else { - // ungzip if browser not send gzip accepted header - var gr *gzip.Reader - gr, _ = gzip.NewReader(bytes.NewReader(bs)) - bs, _ = ioutil.ReadAll(gr) - gr.Close() - } - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(bs))) - w.Header().Set("Last-Modified", modified.UTC().Format(http.TimeFormat)) - // Strictly, no-cache means the same as this. However FF and IE treat no-cache as - // "don't hold a local cache at all", whereas everyone seems to treat this as - // you can hold a local cache, but you must revalidate it before using it. - w.Header().Set("Cache-Control", "max-age=0, must-revalidate") - - w.Write(bs) -} - -func (s embeddedStatic) mimeTypeForFile(file string) string { - // We use a built in table of the common types since the system - // TypeByExtension might be unreliable. But if we don't know, we delegate - // to the system. - ext := filepath.Ext(file) - switch ext { - case ".htm", ".html": - return "text/html" - case ".css": - return "text/css" - case ".js": - return "application/javascript" - case ".json": - return "application/json" - case ".png": - return "image/png" - case ".ttf": - return "application/x-font-ttf" - case ".woff": - return "application/x-font-woff" - case ".svg": - return "image/svg+xml" - default: - return mime.TypeByExtension(ext) - } -} - -// VerifyConfiguration implements the config.Committer interface -func (s *embeddedStatic) VerifyConfiguration(from, to config.Configuration) error { - return nil -} - -// CommitConfiguration implements the config.Committer interface -func (s *embeddedStatic) CommitConfiguration(from, to config.Configuration) bool { - s.mut.Lock() - if s.theme != to.GUI.Theme { - s.theme = to.GUI.Theme - s.lastModified = time.Now() - } - s.mut.Unlock() - - return true -} - -func (s *embeddedStatic) String() string { - return fmt.Sprintf("embeddedStatic@%p", s) -} - func (s *apiService) toNeedSlice(fs []db.FileInfoTruncated) []jsonDBFileInfo { res := make([]jsonDBFileInfo, len(fs)) for i, f := range fs { diff --git a/cmd/syncthing/gui_statics.go b/cmd/syncthing/gui_statics.go new file mode 100644 index 000000000..57ff1013b --- /dev/null +++ b/cmd/syncthing/gui_statics.go @@ -0,0 +1,176 @@ +// Copyright (C) 2014 The Syncthing Authors. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package main + +import ( + "bytes" + "compress/gzip" + "fmt" + "io/ioutil" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/syncthing/syncthing/lib/auto" + "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/sync" +) + +type staticsServer struct { + assetDir string + assets map[string][]byte + availableThemes []string + + mut sync.RWMutex + theme string +} + +func newStaticsServer(theme, assetDir string) *staticsServer { + s := &staticsServer{ + assetDir: assetDir, + assets: auto.Assets(), + mut: sync.NewRWMutex(), + theme: theme, + } + + seen := make(map[string]struct{}) + // Load themes from compiled in assets. + for file := range auto.Assets() { + theme := strings.Split(file, "/")[0] + if _, ok := seen[theme]; !ok { + seen[theme] = struct{}{} + s.availableThemes = append(s.availableThemes, theme) + } + } + if assetDir != "" { + // Load any extra themes from the asset override dir. + for _, dir := range dirNames(assetDir) { + if _, ok := seen[dir]; !ok { + seen[dir] = struct{}{} + s.availableThemes = append(s.availableThemes, dir) + } + } + } + + return s +} + +func (s *staticsServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/themes.json": + s.serveThemes(w, r) + default: + s.serveAsset(w, r) + } +} + +func (s *staticsServer) serveAsset(w http.ResponseWriter, r *http.Request) { + file := r.URL.Path + + if file[0] == '/' { + file = file[1:] + } + + if len(file) == 0 { + file = "index.html" + } + + s.mut.RLock() + theme := s.theme + s.mut.RUnlock() + + // Check for an override for the current theme. + if s.assetDir != "" { + p := filepath.Join(s.assetDir, theme, filepath.FromSlash(file)) + if _, err := os.Stat(p); err == nil { + http.ServeFile(w, r, p) + return + } + } + + // Check for a compiled in asset for the current theme. + bs, ok := s.assets[theme+"/"+file] + if !ok { + // Check for an overridden default asset. + if s.assetDir != "" { + p := filepath.Join(s.assetDir, config.DefaultTheme, filepath.FromSlash(file)) + if _, err := os.Stat(p); err == nil { + http.ServeFile(w, r, p) + return + } + } + + // Check for a compiled in default asset. + bs, ok = s.assets[config.DefaultTheme+"/"+file] + if !ok { + http.NotFound(w, r) + return + } + } + + mtype := s.mimeTypeForFile(file) + if len(mtype) != 0 { + w.Header().Set("Content-Type", mtype) + } + if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + w.Header().Set("Content-Encoding", "gzip") + } else { + // ungzip if browser not send gzip accepted header + var gr *gzip.Reader + gr, _ = gzip.NewReader(bytes.NewReader(bs)) + bs, _ = ioutil.ReadAll(gr) + gr.Close() + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(bs))) + + w.Write(bs) +} + +func (s *staticsServer) serveThemes(w http.ResponseWriter, r *http.Request) { + sendJSON(w, map[string][]string{ + "themes": s.availableThemes, + }) +} + +func (s *staticsServer) mimeTypeForFile(file string) string { + // We use a built in table of the common types since the system + // TypeByExtension might be unreliable. But if we don't know, we delegate + // to the system. + ext := filepath.Ext(file) + switch ext { + case ".htm", ".html": + return "text/html" + case ".css": + return "text/css" + case ".js": + return "application/javascript" + case ".json": + return "application/json" + case ".png": + return "image/png" + case ".ttf": + return "application/x-font-ttf" + case ".woff": + return "application/x-font-woff" + case ".svg": + return "image/svg+xml" + default: + return mime.TypeByExtension(ext) + } +} + +func (s *staticsServer) setTheme(theme string) { + s.mut.Lock() + s.theme = theme + s.mut.Unlock() +} + +func (s *staticsServer) String() string { + return fmt.Sprintf("staticsServer@%p", s) +} diff --git a/cmd/syncthing/gui_test.go b/cmd/syncthing/gui_test.go index 2f6ece6ab..4dbf76497 100644 --- a/cmd/syncthing/gui_test.go +++ b/cmd/syncthing/gui_test.go @@ -116,7 +116,7 @@ func TestAssetsDir(t *testing.T) { gw.Close() foo := buf.Bytes() - e := embeddedStatic{ + e := &staticsServer{ theme: "foo", mut: sync.NewRWMutex(), assetDir: "testdata", diff --git a/gui/default/syncthing/core/syncthingController.js b/gui/default/syncthing/core/syncthingController.js index a826f6fbc..92a886387 100755 --- a/gui/default/syncthing/core/syncthingController.js +++ b/gui/default/syncthing/core/syncthingController.js @@ -49,6 +49,7 @@ angular.module('syncthing.core') $scope.failedCurrentFolder = undefined; $scope.failedPageSize = 10; $scope.scanProgress = {}; + $scope.themes = []; $scope.localStateTotal = { bytes: 0, @@ -88,6 +89,7 @@ angular.module('syncthing.core') refreshConnectionStats(); refreshDeviceStats(); refreshFolderStats(); + refreshThemes(); $http.get(urlbase + '/system/version').success(function (data) { if ($scope.version.version && $scope.version.version !== data.version) { @@ -599,6 +601,12 @@ angular.module('syncthing.core') }).error($scope.emitHTTPError); }, 2500); + var refreshThemes = debounce(function () { + $http.get("themes.json").success(function (data) { // no urlbase here as this is served by the asset handler + $scope.themes = data.themes; + }).error($scope.emitHTTPError); + }, 2500); + $scope.refresh = function () { refreshSystem(); refreshConnectionStats(); @@ -627,7 +635,7 @@ angular.module('syncthing.core') return 'outofsync'; } if (state === 'scanning') { - return state; + return state; } if (folderCfg.devices.length <= 1) { diff --git a/gui/default/syncthing/settings/settingsModalView.html b/gui/default/syncthing/settings/settingsModalView.html index b8b4abbb2..eb4f91cc7 100644 --- a/gui/default/syncthing/settings/settingsModalView.html +++ b/gui/default/syncthing/settings/settingsModalView.html @@ -141,10 +141,10 @@ -