cmd/strelaypoolsrv, lib/api: Factor out static asset serving (#6624)

This commit is contained in:
greatroar 2020-05-10 11:44:34 +02:00 committed by GitHub
parent da99203dcd
commit 06365e5635
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 219 additions and 133 deletions

View File

@ -687,7 +687,7 @@ func listFiles(dir string) []string {
func rebuildAssets() { func rebuildAssets() {
os.Setenv("SOURCE_DATE_EPOCH", fmt.Sprint(buildStamp())) os.Setenv("SOURCE_DATE_EPOCH", fmt.Sprint(buildStamp()))
runPrint(goCmd, "generate", "github.com/syncthing/syncthing/lib/auto", "github.com/syncthing/syncthing/cmd/strelaypoolsrv/auto") runPrint(goCmd, "generate", "github.com/syncthing/syncthing/lib/api/auto", "github.com/syncthing/syncthing/cmd/strelaypoolsrv/auto")
} }
func lazyRebuildAssets() { func lazyRebuildAssets() {

View File

@ -12,7 +12,6 @@ import (
"io" "io"
"io/ioutil" "io/ioutil"
"log" "log"
"mime"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@ -27,6 +26,7 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/syncthing/syncthing/cmd/strelaypoolsrv/auto" "github.com/syncthing/syncthing/cmd/strelaypoolsrv/auto"
"github.com/syncthing/syncthing/lib/assets"
"github.com/syncthing/syncthing/lib/rand" "github.com/syncthing/syncthing/lib/rand"
"github.com/syncthing/syncthing/lib/relay/client" "github.com/syncthing/syncthing/lib/relay/client"
"github.com/syncthing/syncthing/lib/sync" "github.com/syncthing/syncthing/lib/sync"
@ -263,78 +263,22 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) {
func handleAssets(w http.ResponseWriter, r *http.Request) { func handleAssets(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, must-revalidate") w.Header().Set("Cache-Control", "no-cache, must-revalidate")
assets := auto.Assets()
path := r.URL.Path[1:] path := r.URL.Path[1:]
if path == "" { if path == "" {
path = "index.html" path = "index.html"
} }
bs, ok := assets[path] content, ok := auto.Assets()[path]
if !ok { if !ok {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
etag := fmt.Sprintf("%d", auto.Generated) assets.Serve(w, r, assets.Asset{
modified := time.Unix(auto.Generated, 0).UTC() ContentGz: content,
Filename: path,
w.Header().Set("Last-Modified", modified.Format(http.TimeFormat)) Modified: time.Unix(auto.Generated, 0).UTC(),
w.Header().Set("Etag", etag) })
mtype := mimeTypeForFile(path)
if len(mtype) != 0 {
w.Header().Set("Content-Type", mtype)
}
if t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since")); err == nil && modified.Add(time.Second).After(t) {
w.WriteHeader(http.StatusNotModified)
return
}
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
w.WriteHeader(http.StatusNotModified)
return
}
}
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Length", strconv.Itoa(len(bs)))
io.WriteString(w, bs)
} else {
// ungzip if browser not send gzip accepted header
var gr *gzip.Reader
gr, _ = gzip.NewReader(strings.NewReader(bs))
io.Copy(w, gr)
gr.Close()
}
}
func 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 handleRequest(w http.ResponseWriter, r *http.Request) { func handleRequest(w http.ResponseWriter, r *http.Request) {

View File

@ -7,18 +7,15 @@
package api package api
import ( import (
"compress/gzip"
"fmt" "fmt"
"io"
"mime"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
"github.com/syncthing/syncthing/lib/auto" "github.com/syncthing/syncthing/lib/api/auto"
"github.com/syncthing/syncthing/lib/assets"
"github.com/syncthing/syncthing/lib/config" "github.com/syncthing/syncthing/lib/config"
"github.com/syncthing/syncthing/lib/sync" "github.com/syncthing/syncthing/lib/sync"
) )
@ -111,7 +108,7 @@ func (s *staticsServer) serveAsset(w http.ResponseWriter, r *http.Request) {
if s.assetDir != "" { if s.assetDir != "" {
p := filepath.Join(s.assetDir, theme, filepath.FromSlash(file)) p := filepath.Join(s.assetDir, theme, filepath.FromSlash(file))
if _, err := os.Stat(p); err == nil { if _, err := os.Stat(p); err == nil {
mtype := s.mimeTypeForFile(file) mtype := assets.MimeTypeForFile(file)
if len(mtype) != 0 { if len(mtype) != 0 {
w.Header().Set("Content-Type", mtype) w.Header().Set("Content-Type", mtype)
} }
@ -127,7 +124,7 @@ func (s *staticsServer) serveAsset(w http.ResponseWriter, r *http.Request) {
if s.assetDir != "" { if s.assetDir != "" {
p := filepath.Join(s.assetDir, config.DefaultTheme, filepath.FromSlash(file)) p := filepath.Join(s.assetDir, config.DefaultTheme, filepath.FromSlash(file))
if _, err := os.Stat(p); err == nil { if _, err := os.Stat(p); err == nil {
mtype := s.mimeTypeForFile(file) mtype := assets.MimeTypeForFile(file)
if len(mtype) != 0 { if len(mtype) != 0 {
w.Header().Set("Content-Type", mtype) w.Header().Set("Content-Type", mtype)
} }
@ -144,39 +141,11 @@ func (s *staticsServer) serveAsset(w http.ResponseWriter, r *http.Request) {
} }
} }
etag := fmt.Sprintf("%d", modificationTime.Unix()) assets.Serve(w, r, assets.Asset{
w.Header().Set("Last-Modified", modificationTime.Format(http.TimeFormat)) ContentGz: bs,
w.Header().Set("Etag", etag) Filename: file,
Modified: modificationTime,
if t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since")); err == nil { })
if modificationTime.Equal(t) || modificationTime.Before(t) {
w.WriteHeader(http.StatusNotModified)
return
}
}
if match := r.Header.Get("If-None-Match"); match != "" {
if strings.Contains(match, etag) {
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")
w.Header().Set("Content-Length", strconv.Itoa(len(bs)))
io.WriteString(w, bs)
} else {
// ungzip if browser not send gzip accepted header
var gr *gzip.Reader
gr, _ = gzip.NewReader(strings.NewReader(bs))
io.Copy(w, gr)
gr.Close()
}
} }
func (s *staticsServer) serveThemes(w http.ResponseWriter, r *http.Request) { func (s *staticsServer) serveThemes(w http.ResponseWriter, r *http.Request) {
@ -185,33 +154,6 @@ func (s *staticsServer) serveThemes(w http.ResponseWriter, r *http.Request) {
}) })
} }
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. All our files are UTF-8.
ext := filepath.Ext(file)
switch ext {
case ".htm", ".html":
return "text/html; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".js":
return "application/javascript; charset=utf-8"
case ".json":
return "application/json; charset=utf-8"
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; charset=utf-8"
default:
return mime.TypeByExtension(ext)
}
}
func (s *staticsServer) setTheme(theme string) { func (s *staticsServer) setTheme(theme string) {
s.mut.Lock() s.mut.Lock()
s.theme = theme s.theme = theme

View File

@ -13,7 +13,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/syncthing/syncthing/lib/auto" "github.com/syncthing/syncthing/lib/api/auto"
) )
func TestAssets(t *testing.T) { func TestAssets(t *testing.T) {

View File

@ -4,7 +4,7 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this file, // License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/. // You can obtain one at https://mozilla.org/MPL/2.0/.
//go:generate go run ../../script/genassets.go -o gui.files.go ../../gui //go:generate go run ../../../script/genassets.go -o gui.files.go ../../../gui
// Package auto contains auto generated files for web assets. // Package auto contains auto generated files for web assets.
package auto package auto

97
lib/assets/assets.go Normal file
View File

@ -0,0 +1,97 @@
// Copyright (C) 2014-2020 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 https://mozilla.org/MPL/2.0/.
// Package assets hold utilities for serving static assets.
//
// The actual assets live in auto subpackages instead of here,
// because the set of assets varies per program.
package assets
import (
"compress/gzip"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
)
// Asset is the type of arguments to Serve.
type Asset struct {
ContentGz string // gzipped contents of asset.
Filename string // Original filename, determines Content-Type.
Modified time.Time // Determines ETag and Last-Modified.
}
// Serve writes a gzipped asset to w.
func Serve(w http.ResponseWriter, r *http.Request, asset Asset) {
header := w.Header()
mtype := MimeTypeForFile(asset.Filename)
if mtype != "" {
header.Set("Content-Type", mtype)
}
etag := fmt.Sprintf(`"%x"`, asset.Modified.Unix())
header.Set("ETag", etag)
header.Set("Last-Modified", asset.Modified.Format(http.TimeFormat))
t, err := time.Parse(http.TimeFormat, r.Header.Get("If-Modified-Since"))
if err == nil && !asset.Modified.After(t) {
w.WriteHeader(http.StatusNotModified)
return
}
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
header.Set("Content-Encoding", "gzip")
header.Set("Content-Length", strconv.Itoa(len(asset.ContentGz)))
io.WriteString(w, asset.ContentGz)
} else {
// gunzip for browsers that don't want gzip.
var gr *gzip.Reader
gr, _ = gzip.NewReader(strings.NewReader(asset.ContentGz))
io.Copy(w, gr)
gr.Close()
}
}
// MimeTypeForFile returns the appropriate MIME type for an asset,
// based on the filename.
//
// 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. All our text files are in UTF-8.
func MimeTypeForFile(file string) string {
ext := filepath.Ext(file)
switch ext {
case ".htm", ".html":
return "text/html; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".js":
return "application/javascript; charset=utf-8"
case ".json":
return "application/json; charset=utf-8"
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; charset=utf-8"
default:
return mime.TypeByExtension(ext)
}
}

103
lib/assets/assets_test.go Normal file
View File

@ -0,0 +1,103 @@
// Copyright (C) 2020 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 https://mozilla.org/MPL/2.0/.
package assets
import (
"bytes"
"compress/gzip"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func compress(s string) string {
var sb strings.Builder
gz := gzip.NewWriter(&sb)
io.WriteString(gz, s)
gz.Close()
return sb.String()
}
func decompress(p []byte) (out []byte) {
r, err := gzip.NewReader(bytes.NewBuffer(p))
if err == nil {
out, err = ioutil.ReadAll(r)
}
if err != nil {
panic(err)
}
return out
}
func TestServe(t *testing.T) {
indexHTML := `<html>Hello, world!</html>`
indexGz := compress(indexHTML)
handler := func(w http.ResponseWriter, r *http.Request) {
Serve(w, r, Asset{
ContentGz: indexGz,
Filename: r.URL.Path[1:],
Modified: time.Unix(0, 0),
})
}
for _, acceptGzip := range []bool{true, false} {
r := httptest.NewRequest("GET", "http://localhost/index.html", nil)
if acceptGzip {
r.Header.Set("accept-encoding", "gzip, deflate")
}
w := httptest.NewRecorder()
handler(w, r)
res := w.Result()
if res.StatusCode != http.StatusOK {
t.Fatalf("wanted OK, got status %d", res.StatusCode)
}
if ctype := res.Header.Get("Content-Type"); ctype != "text/html; charset=utf-8" {
t.Errorf("unexpected Content-Type %q", ctype)
}
// ETags must be quoted ASCII strings:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
if etag := res.Header.Get("ETag"); etag != `"0"` {
t.Errorf("unexpected ETag %q", etag)
}
body, _ := ioutil.ReadAll(res.Body)
if acceptGzip {
body = decompress(body)
}
if string(body) != indexHTML {
t.Fatalf("unexpected content %q", body)
}
}
r := httptest.NewRequest("GET", "http://localhost/index.html", nil)
r.Header.Set("if-none-match", `"0"`)
w := httptest.NewRecorder()
handler(w, r)
res := w.Result()
if res.StatusCode != http.StatusNotModified {
t.Fatalf("wanted NotModified, got status %d", res.StatusCode)
}
r = httptest.NewRequest("GET", "http://localhost/index.html", nil)
r.Header.Set("if-modified-since", time.Now().Format(http.TimeFormat))
w = httptest.NewRecorder()
handler(w, r)
res = w.Result()
if res.StatusCode != http.StatusNotModified {
t.Fatalf("wanted NotModified, got status %d", res.StatusCode)
}
}