Commit 80f0df2e authored by ale's avatar ale

Serve pre-compressed static resources

Fix also some errors in the status template, and update to Bootstrap
4.3.1 and JQuery 3.4.0.
parent 050f2525
SOURCES = \
static/css/style.css \
static/css/bootstrap.min.css \
static/js/bootstrap.bundle.min.js \
static/js/jquery-3.4.0.slim.min.js \
static/js/autoradio.js
COMPRESSED = \
$(SOURCES:%=%.br) \
$(SOURCES:%=%.gz)
%.br: %
brotli --input $^ --output $@
%.gz: %
zopfli $^
all: $(COMPRESSED)
clean:
-rm -f $(COMPRESSED)
This diff is collapsed.
package node
//go:generate make
//go:generate go-bindata --nocompress --pkg node static/... templates/...
import (
......@@ -21,6 +22,7 @@ import (
"git.autistici.org/ale/autoradio"
pb "git.autistici.org/ale/autoradio/proto"
assetfs "github.com/elazarl/go-bindata-assetfs"
"github.com/lpar/gzipped"
)
var (
......@@ -34,7 +36,7 @@ func newHTTPHandler(n *Node, icecastPort int, domain string) http.Handler {
tpl := mustParseEmbeddedTemplates()
// Serve /static/ from builtin assets.
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(&assetfs.AssetFS{
mux.Handle("/static/", http.StripPrefix("/static/", gzipped.FileServer(&assetfs.AssetFS{
Asset: Asset,
AssetDir: AssetDir,
AssetInfo: AssetInfo,
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -13,7 +13,7 @@ body {
}
.page-header {
background: url(radio52.png) top left no-repeat;
background: url(../radio52.png) top left no-repeat;
padding-left: 58px;
}
......
$(function() {
$('[data-toggle="tooltip"]').tooltip();
});
This diff is collapsed.
This diff is collapsed.
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>{{.Domain}}</title>
<link rel="stylesheet"
href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="shortcut icon" href="/static/radio52.png">
</head>
<body>
......@@ -31,16 +30,16 @@
{{end}}
>{{.Mount.Path}}</a>
<a href="http://{{$domain}}{{.Mount.Path}}.m3u">(m3u)</a>
<span class="badge">{{.Listeners}}</span>
<span class="badge badge-secondary">{{.Listeners}}</span>
{{if .TransMounts}}
<ul>
{{range .TransMounts}}
<li>
<a href="http://{{$domain}}{{.Mount.Path}}"
data-toggle="tooltip" data-delay="300" title="{{.Mount.Transcoding.String}}"
data-toggle="tooltip" data-delay="300" title="{{.Mount.TranscodeParams.String}}"
>{{.Mount.Path}}</a>
<a href="http://{{$domain}}{{.Mount.Path}}.m3u">(m3u)</a>
<span class="badge">{{.Listeners}}</span>
<span class="badge badge-secondary">{{.Listeners}}</span>
</li>
{{end}}
</ul>
......@@ -55,7 +54,7 @@
<ul>
{{range .Nodes}}
<li>
{{.Name}} <span class="badge">{{.NumListeners}}</span>
{{.Name}} <span class="badge badge-secondary">{{.NumListeners}}</span>
{{if not .IcecastOk}}<span class="label label-danger">IC_DOWN</span>{{end}}
</li>
{{end}}
......@@ -71,12 +70,8 @@
</div>
</div>
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
<script type="text/javascript" src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
<script type="text/javascript">
$(function() {
$('[data-toggle="tooltip"]').tooltip();
});
</script>
<script type="text/javascript" src="/static/js/jquery-3.4.0.slim.min.js"></script>
<script type="text/javascript" src="/static/js/bootstrap.bundle.min.js"></script>
<script type="text/javascript" src="/static/js/autoradio.js"></script>
</body>
</html>
Copyright (c) 2013 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
// Copyright 2013 The Go Authors. All rights reserved.
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd.
// Package header provides functions for parsing HTTP headers.
package header
import (
"net/http"
"strings"
"time"
)
// Octet types from RFC 2616.
var octetTypes [256]octetType
type octetType byte
const (
isToken octetType = 1 << iota
isSpace
)
func init() {
// OCTET = <any 8-bit sequence of data>
// CHAR = <any US-ASCII character (octets 0 - 127)>
// CTL = <any US-ASCII control character (octets 0 - 31) and DEL (127)>
// CR = <US-ASCII CR, carriage return (13)>
// LF = <US-ASCII LF, linefeed (10)>
// SP = <US-ASCII SP, space (32)>
// HT = <US-ASCII HT, horizontal-tab (9)>
// <"> = <US-ASCII double-quote mark (34)>
// CRLF = CR LF
// LWS = [CRLF] 1*( SP | HT )
// TEXT = <any OCTET except CTLs, but including LWS>
// separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <">
// | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT
// token = 1*<any CHAR except CTLs or separators>
// qdtext = <any TEXT except <">>
for c := 0; c < 256; c++ {
var t octetType
isCtl := c <= 31 || c == 127
isChar := 0 <= c && c <= 127
isSeparator := strings.IndexRune(" \t\"(),/:;<=>?@[]\\{}", rune(c)) >= 0
if strings.IndexRune(" \t\r\n", rune(c)) >= 0 {
t |= isSpace
}
if isChar && !isCtl && !isSeparator {
t |= isToken
}
octetTypes[c] = t
}
}
// Copy returns a shallow copy of the header.
func Copy(header http.Header) http.Header {
h := make(http.Header)
for k, vs := range header {
h[k] = vs
}
return h
}
var timeLayouts = []string{"Mon, 02 Jan 2006 15:04:05 GMT", time.RFC850, time.ANSIC}
// ParseTime parses the header as time. The zero value is returned if the
// header is not present or there is an error parsing the
// header.
func ParseTime(header http.Header, key string) time.Time {
if s := header.Get(key); s != "" {
for _, layout := range timeLayouts {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
}
return time.Time{}
}
// ParseList parses a comma separated list of values. Commas are ignored in
// quoted strings. Quoted values are not unescaped or unquoted. Whitespace is
// trimmed.
func ParseList(header http.Header, key string) []string {
var result []string
for _, s := range header[http.CanonicalHeaderKey(key)] {
begin := 0
end := 0
escape := false
quote := false
for i := 0; i < len(s); i++ {
b := s[i]
switch {
case escape:
escape = false
end = i + 1
case quote:
switch b {
case '\\':
escape = true
case '"':
quote = false
}
end = i + 1
case b == '"':
quote = true
end = i + 1
case octetTypes[b]&isSpace != 0:
if begin == end {
begin = i + 1
end = begin
}
case b == ',':
if begin < end {
result = append(result, s[begin:end])
}
begin = i + 1
end = begin
default:
end = i + 1
}
}
if begin < end {
result = append(result, s[begin:end])
}
}
return result
}
// ParseValueAndParams parses a comma separated list of values with optional
// semicolon separated name-value pairs. Content-Type and Content-Disposition
// headers are in this format.
func ParseValueAndParams(header http.Header, key string) (value string, params map[string]string) {
params = make(map[string]string)
s := header.Get(key)
value, s = expectTokenSlash(s)
if value == "" {
return
}
value = strings.ToLower(value)
s = skipSpace(s)
for strings.HasPrefix(s, ";") {
var pkey string
pkey, s = expectToken(skipSpace(s[1:]))
if pkey == "" {
return
}
if !strings.HasPrefix(s, "=") {
return
}
var pvalue string
pvalue, s = expectTokenOrQuoted(s[1:])
if pvalue == "" {
return
}
pkey = strings.ToLower(pkey)
params[pkey] = pvalue
s = skipSpace(s)
}
return
}
// AcceptSpec describes an Accept* header.
type AcceptSpec struct {
Value string
Q float64
}
// ParseAccept parses Accept* headers.
func ParseAccept(header http.Header, key string) (specs []AcceptSpec) {
loop:
for _, s := range header[key] {
for {
var spec AcceptSpec
spec.Value, s = expectTokenSlash(s)
if spec.Value == "" {
continue loop
}
spec.Q = 1.0
s = skipSpace(s)
if strings.HasPrefix(s, ";") {
s = skipSpace(s[1:])
if !strings.HasPrefix(s, "q=") {
continue loop
}
spec.Q, s = expectQuality(s[2:])
if spec.Q < 0.0 {
continue loop
}
}
specs = append(specs, spec)
s = skipSpace(s)
if !strings.HasPrefix(s, ",") {
continue loop
}
s = skipSpace(s[1:])
}
}
return
}
func skipSpace(s string) (rest string) {
i := 0
for ; i < len(s); i++ {
if octetTypes[s[i]]&isSpace == 0 {
break
}
}
return s[i:]
}
func expectToken(s string) (token, rest string) {
i := 0
for ; i < len(s); i++ {
if octetTypes[s[i]]&isToken == 0 {
break
}
}
return s[:i], s[i:]
}
func expectTokenSlash(s string) (token, rest string) {
i := 0
for ; i < len(s); i++ {
b := s[i]
if (octetTypes[b]&isToken == 0) && b != '/' {
break
}
}
return s[:i], s[i:]
}
func expectQuality(s string) (q float64, rest string) {
switch {
case len(s) == 0:
return -1, ""
case s[0] == '0':
q = 0
case s[0] == '1':
q = 1
default:
return -1, ""
}
s = s[1:]
if !strings.HasPrefix(s, ".") {
return q, s
}
s = s[1:]
i := 0
n := 0
d := 1
for ; i < len(s); i++ {
b := s[i]
if b < '0' || b > '9' {
break
}
n = n*10 + int(b) - '0'
d *= 10
}
return q + float64(n)/float64(d), s[i:]
}
func expectTokenOrQuoted(s string) (value string, rest string) {
if !strings.HasPrefix(s, "\"") {
return expectToken(s)
}
s = s[1:]
for i := 0; i < len(s); i++ {
switch s[i] {
case '"':
return s[:i], s[i+1:]
case '\\':
p := make([]byte, len(s)-1)
j := copy(p, s[:i])
escape := true
for i = i + 1; i < len(s); i++ {
b := s[i]
switch {
case escape:
escape = false
p[j] = b
j++
case b == '\\':
escape = true
case b == '"':
return string(p[:j]), s[i+1:]
default:
p[j] = b
j++
}
}
return "", ""
}
}
return "", ""
}
Copyright (c) 2016, IBM Corporation. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of IBM nor the names of project contributors may be used
to endorse or promote products derived from this software without specific
prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[![GoDoc](https://godoc.org/github.com/lpar/gzipped?status.svg)](https://godoc.org/github.com/lpar/gzipped)
# gzipped.FileServer
Drop-in replacement for golang http.FileServer which supports static content
compressed with gzip (including zopfli) or brotli.
This allows major bandwidth savings for CSS, JavaScript libraries, fonts, and
other static compressible web content. It also means you can compress the
content without significant runtime penalty.
## Example
Suppose `/var/www/assets/css` contains your style sheets, and you want to make them available as `/css/*.css`:
package main
import (
"log"
"net/http"
"github.com/lpar/gzipped"
)
func main() {
log.Fatal(http.ListenAndServe(":8080", http.StripPrefix("/css",
gzipped.FileServer(http.Dir("/var/www/assets/css")))))
}
// curl localhost:8080/css/styles.css
Using [httprouter](https://github.com/julienschmidt/httprouter)?
router := httprouter.New()
router.Handler("GET", "/css/*filepath",
gzipped.FileServer(http.Dir("/var/www/assets/css"))))
log.Fatal(http.ListenAndServe(":8080", router)
## Detail
For any given request at `/path/filename.ext`, if:
1. There exists a file named `/path/filename.ext.(gz|br)` (starting from the
appropriate base directory), and
2. the client will accept content compressed via the appropriate algorithm, and
3. the file can be opened,
then the compressed file will be served as `/path/filename.ext`, with a
`Content-Encoding` header set so that the client transparently decompresses it.
Otherwise, the request is passed through and handled unchanged.
Unlike other similar code I found, this package has a license, parses
Accept-Encoding headers properly, and has unit tests.
## Caveats
All requests are passed to Go's standard `http.ServeContent` method for
fulfilment. MIME type sniffing, accept ranges, content negotiation and other
tricky details are handled by that method.
It is up to you to ensure that your compressed and uncompressed resources are
kept in sync.
Directory browsing isn't supported. (You probably don't want it on your
application anyway, and if you do then you probably don't want Go's default
implementation.)
## Related
* You might consider precompressing your CSS with [minify](https://github.com/tdewolff/minify).
* If you want to get the best possible compression for clients which don't support brotli, use [zopfli](https://github.com/google/zopfli).
* To compress your dynamically-generated HTML pages on the fly, I suggest [gziphandler](https://github.com/NYTimes/gziphandler).
package gzipped
import (
"fmt"
"net/http"
"os"
"path"
"sort"
"strings"
"github.com/golang/gddo/httputil/header"
)
// Encoding represents an Accept-Encoding. All of these fields are pre-populated
// in the supportedEncodings variable, except the clientPreference which is updated
// (by copying a value from supportedEncodings) when examining client headers.
type encoding struct {
name string // the encoding name
extension string // the file extension (including a leading dot)
clientPreference float64 // the client's preference
serverPreference int // the server's preference
}
// Helper type to sort encodings, using clientPreference first, and then
// serverPreference as a tie breaker. This sorts in *DESCENDING* order, rather
// than the usual ascending order.
type encodingByPreference []encoding
// Implement the sort.Interface interface
func (e encodingByPreference) Len() int { return len(e) }
func (e encodingByPreference) Less(i, j int) bool {
if e[i].clientPreference == e[j].clientPreference {
return e[i].serverPreference > e[j].serverPreference
}
return e[i].clientPreference > e[j].clientPreference
}
func (e encodingByPreference) Swap(i, j int) { e[i], e[j] = e[j], e[i] }
// Supported encodings. Higher server preference means the encoding will be when
// the client doesn't have an explicit preference.
var supportedEncodings = [...]encoding{
{
name: "gzip",
extension: ".gz",
serverPreference: 1,
},
{
name: "br",
extension: ".br",
serverPreference: 2,
},
}
type fileHandler struct {
root http.FileSystem
}
// FileServer is a drop-in replacement for Go's standard http.FileServer
// which adds support for static resources precompressed with gzip, at
// the cost of removing the support for directory browsing.
//
// If file filename.ext has a compressed version filename.ext.gz alongside
// it, if the client indicates that it accepts gzip-compressed data, and
// if the .gz file can be opened, then the compressed version of the file
// will be sent to the client. Otherwise the request is passed on to
// http.ServeContent, and the raw (uncompressed) version is used.
//
// It is up to you to ensure that the compressed and uncompressed versions
// of files match and have sensible timestamps.
//
// Compressed or not, requests are fulfilled using http.ServeContent, and
// details like accept ranges and content-type sniffing are handled by that
// method.
func FileServer(root http.FileSystem) http.Handler {
return &fileHandler{root}
}
func (f *fileHandler) openAndStat(path string) (http.File, os.FileInfo, error) {
file, err := f.root.Open(path)
var info os.FileInfo
// This slightly weird variable reuse is so we can get 100% test coverage
// without having to come up with a test file that can be opened, yet
// fails to stat.
if err == nil {
info, err = file.Stat()
}
if err != nil {
return file, nil, err
}
if info.IsDir() {
return file, nil, fmt.Errorf("%s is directory", path)
}
return file, info, nil
}
// Build a []encoding based on the Accept-Encoding header supplied by the
// client. The returned list will be sorted from most-preferred to
// least-preferred.
func acceptable(r *http.Request) []encoding {
// list of acceptable encodings, as provided by the client
acceptEncodings := make([]encoding, 0, len(supportedEncodings))
// the quality of the * encoding; this will be -1 if not sent by client
starQuality := -1.
// encodings we've already seen (used to handle duplicates and *)
seenEncodings := make(map[string]interface{})
// match the client accept encodings against the ones we support
for _, aspec := range header.ParseAccept(r.Header, "Accept-Encoding") {
if _, alreadySeen := seenEncodings[aspec.Value]; alreadySeen {
continue
}
seenEncodings[aspec.Value] = nil
if aspec.Value == "*" {
starQuality = aspec.Q
continue
}
for _, known := range supportedEncodings {
if aspec.Value == known.name && aspec.Q != 0 {
enc := known
enc.clientPreference = aspec.Q
acceptEncodings = append(acceptEncodings, enc)
break
}
}
}
// If the client sent Accept: *, add all our extra known encodings. Use
// the quality of * as the client quality for the encoding.
if starQuality != -1. {
for _, known := range supportedEncodings {
if _, seen := seenEncodings[known.name]; !seen {
enc := known
enc.clientPreference = starQuality
acceptEncodings = append(acceptEncodings, enc)
}
}
}
// sort the encoding based on client/server preference
sort.Sort(encodingByPreference(acceptEncodings))
return acceptEncodings
}
// Find the best file to serve based on the client's Accept-Encoding, and which
// files actually exist on the filesystem. If no file was found that can satisfy