From 03a8027efca84409550d1d773fc433a6bef72638 Mon Sep 17 00:00:00 2001
From: Jakob Borg <jakob@nym.se>
Date: Tue, 7 Jun 2016 07:46:45 +0000
Subject: [PATCH] cmd/syncthing: Refactor out staticsServer (prev.
 embeddedStatic) a bit

The purpose of this operation is to separate the serving of GUI assets a
bit from the serving of the REST API. It's by no means complete. The end
goal is something like a combined server type that embeds a statics
server and an API server and wraps it in authentication and HTTPS and
stuff, plus possibly a named pipe server that only provides the API and
does not wrap in the same authentication etc.

GitHub-Pull-Request: https://github.com/syncthing/syncthing/pull/3273
---
 cmd/syncthing/gui.go                          | 174 +----------------
 cmd/syncthing/gui_statics.go                  | 176 ++++++++++++++++++
 cmd/syncthing/gui_test.go                     |   2 +-
 .../syncthing/core/syncthingController.js     |  10 +-
 .../syncthing/settings/settingsModalView.html |   4 +-
 5 files changed, 195 insertions(+), 171 deletions(-)
 create mode 100644 cmd/syncthing/gui_statics.go

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 @@
               </button>
             </div>
 
-            <div class="form-group" ng-if="system.themes.length > 1">
+            <div class="form-group" ng-if="themes.length > 1">
               <label>GUI Theme</label>
               <select class="form-control" ng-model="tmpGUI.theme">
-                <option ng-repeat="theme in system.themes.sort()" value="{{ theme }}">
+                <option ng-repeat="theme in themes.sort()" value="{{ theme }}">
                   {{ themeName(theme) }}
                 </option>
               </select>