diff --git a/cmd/redirectord/redirectord.go b/cmd/redirectord/redirectord.go index 352f166a846b0785569111e1dceb89f2f287b719..ccec7671798eba0a197a2c2e5723be439d41f5b6 100644 --- a/cmd/redirectord/redirectord.go +++ b/cmd/redirectord/redirectord.go @@ -4,8 +4,6 @@ import ( "flag" "fmt" "log" - "net/http" - "time" "git.autistici.org/ale/radioai" "git.autistici.org/ale/radioai/fe" @@ -17,6 +15,9 @@ var ( httpPort = flag.Int("http-port", 80, "HTTP port") publicIp = flag.String("ip", "127.0.0.1", "Public IP for this machine") + staticDir = flag.String("static-dir", "./static", "Static content directory") + templateDir = flag.String("template-dir", "./templates", "HTML templates directory") + // Default DNS TTL (seconds). dnsTtl = 5 ) @@ -30,18 +31,10 @@ func main() { client := radioai.NewEtcdClient() api := radioai.NewRadioAPI(client) - red := fe.NewHttpRedirector(api) dnsRed := fe.NewDnsRedirector(api, *domain, *publicIp, dnsTtl) dnsRed.Run(fmt.Sprintf(":%d", *dnsPort)) - httpServer := &http.Server{ - Addr: fmt.Sprintf(":%d", *httpPort), - Handler: red, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - } - - log.Printf("starting HTTP server on %s/tcp", httpServer.Addr) - log.Fatal(httpServer.ListenAndServe()) + red := fe.NewHttpRedirector(api, *domain) + red.Run(fmt.Sprintf(":%d", *httpPort), *staticDir, *templateDir) } diff --git a/fe/gzip.go b/fe/gzip.go new file mode 100644 index 0000000000000000000000000000000000000000..9513dbb50d05a92cc5d8be72a9a4d3a03e4f7b4a --- /dev/null +++ b/fe/gzip.go @@ -0,0 +1,205 @@ +package fe + +import ( + "compress/gzip" + "io" + "net/http" + "strings" +) + +// Slightly modified by ale@incal.net, based on: +// https://github.com/PuerkitoBio/ghost + +// Thanks to Andrew Gerrand for inspiration: +// https://groups.google.com/d/msg/golang-nuts/eVnTcMwNVjM/4vYU8id9Q2UJ +// +// Also, node's Connect library implementation of the compress middleware: +// https://github.com/senchalabs/connect/blob/master/lib/middleware/compress.js +// +// And StackOverflow's explanation of Vary: Accept-Encoding header: +// http://stackoverflow.com/questions/7848796/what-does-varyaccept-encoding-mean + +// Internal gzipped writer that satisfies both the (body) writer in gzipped format, +// and maintains the rest of the ResponseWriter interface for header manipulation. +type gzipResponseWriter struct { + io.Writer + http.ResponseWriter + r *http.Request // Keep a hold of the Request, for the filter function + filtered bool // Has the request been run through the filter function? + dogzip bool // Should we do GZIP compression for this request? + filterFn func(http.ResponseWriter, *http.Request) bool +} + +// Make sure the filter function is applied. +func (w *gzipResponseWriter) applyFilter() { + if !w.filtered { + if w.dogzip = w.filterFn(w, w.r); w.dogzip { + setGzipHeaders(w.Header()) + } + w.filtered = true + } +} + +// Unambiguous Write() implementation (otherwise both ResponseWriter and Writer +// want to claim this method). +func (w *gzipResponseWriter) Write(b []byte) (int, error) { + w.applyFilter() + if w.dogzip { + // Write compressed + return w.Writer.Write(b) + } + // Write uncompressed + return w.ResponseWriter.Write(b) +} + +// Intercept the WriteHeader call to correctly set the GZIP headers. +func (w *gzipResponseWriter) WriteHeader(code int) { + w.applyFilter() + w.ResponseWriter.WriteHeader(code) +} + +// Implement WrapWriter interface +func (w *gzipResponseWriter) WrappedWriter() http.ResponseWriter { + return w.ResponseWriter +} + +var ( + defaultFilterTypes = [...]string{ + "text/", + "javascript", + "json", + } +) + +// Default filter to check if the response should be GZIPped. +// By default, all text (html, css, xml, ...), javascript and json +// content types are candidates for GZIP. +func defaultFilter(w http.ResponseWriter, r *http.Request) bool { + hdr := w.Header() + for _, tp := range defaultFilterTypes { + ok := headerMatch(hdr, "Content-Type", tp) + if ok { + return true + } + } + return false +} + +// GZIPHandlerFunc is the same as GZIPHandler, it is just a convenience +// signature that accepts a func(http.ResponseWriter, *http.Request) instead of +// a http.Handler interface. It saves the boilerplate http.HandlerFunc() cast. +func GZIPHandlerFunc(h http.HandlerFunc, filterFn func(http.ResponseWriter, *http.Request) bool) http.HandlerFunc { + return GZIPHandler(h, filterFn) +} + +// Gzip compression HTTP handler. If the client supports it, it compresses the response +// written by the wrapped handler. The filter function is called when the response is about +// to be written to determine if compression should be applied. If this argument is nil, +// the default filter will GZIP only content types containing /json|text|javascript/. +func GZIPHandler(h http.Handler, filterFn func(http.ResponseWriter, *http.Request) bool) http.HandlerFunc { + if filterFn == nil { + filterFn = defaultFilter + } + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := getGzipWriter(w); ok { + // Self-awareness, gzip handler is already set up + h.ServeHTTP(w, r) + return + } + hdr := w.Header() + setVaryHeader(hdr) + + // Do nothing on a HEAD request + if r.Method == "HEAD" { + h.ServeHTTP(w, r) + return + } + if !acceptsGzip(r.Header) { + // No gzip support from the client, return uncompressed + h.ServeHTTP(w, r) + return + } + + // Prepare a gzip response container + gz := gzip.NewWriter(w) + gzw := &gzipResponseWriter{ + Writer: gz, + ResponseWriter: w, + r: r, + filterFn: filterFn, + } + h.ServeHTTP(gzw, r) + // Iff the handler completed successfully (no panic) and GZIP was indeed used, close the gzip writer, + // which seems to generate a Write to the underlying writer. + if gzw.dogzip { + gz.Close() + } + } +} + +// Add the vary by "accept-encoding" header if it is not already set. +func setVaryHeader(hdr http.Header) { + if !headerMatch(hdr, "Vary", "accept-encoding") { + hdr.Add("Vary", "Accept-Encoding") + } +} + +// Checks if the client accepts GZIP-encoded responses. +func acceptsGzip(hdr http.Header) bool { + ok := headerMatch(hdr, "Accept-Encoding", "gzip") + if !ok { + ok = headerEquals(hdr, "Accept-Encoding", "*") + } + return ok +} + +func setGzipHeaders(hdr http.Header) { + // The content-type will be explicitly set somewhere down the path of handlers + hdr.Set("Content-Encoding", "gzip") + hdr.Del("Content-Length") +} + +// Helper function to retrieve the gzip writer. +func getGzipWriter(w http.ResponseWriter) (*gzipResponseWriter, bool) { + gz, ok := GetResponseWriter(w, func(tst http.ResponseWriter) bool { + _, ok := tst.(*gzipResponseWriter) + return ok + }) + if ok { + return gz.(*gzipResponseWriter), true + } + return nil, false +} + +func headerMatch(hdr http.Header, name, s string) bool { + return strings.Contains(hdr.Get(name), s) +} + +func headerEquals(hdr http.Header, name, s string) bool { + return hdr.Get(name) == s +} + +// This interface can be implemented by an augmented ResponseWriter, so that +// it doesn't hide other augmented writers in the chain. +type WrapWriter interface { + http.ResponseWriter + WrappedWriter() http.ResponseWriter +} + +// Helper function to retrieve a specific ResponseWriter. +func GetResponseWriter(w http.ResponseWriter, + predicate func(http.ResponseWriter) bool) (http.ResponseWriter, bool) { + + for { + // Check if this writer is the one we're looking for + if w != nil && predicate(w) { + return w, true + } + // If it is a WrapWriter, move back the chain of wrapped writers + ww, ok := w.(WrapWriter) + if !ok { + return nil, false + } + w = ww.WrappedWriter() + } +} diff --git a/fe/http.go b/fe/http.go index 6a8b07a44eada84890c87f088fdd3968b909cd7f..cc72036dfa46b79d444dbe3731a23c4e916257ba 100644 --- a/fe/http.go +++ b/fe/http.go @@ -1,11 +1,15 @@ package fe import ( + "bytes" "fmt" + "html/template" "io" + "log" "math/rand" "net/http" "net/http/httputil" + "path/filepath" "strconv" "strings" "time" @@ -23,12 +27,15 @@ import ( // a .m3u file directly pointing at the relays. // type HttpRedirector struct { - client *radioai.RadioAPI + domain string + client *radioai.RadioAPI + template *template.Template } -func NewHttpRedirector(client *radioai.RadioAPI) *HttpRedirector { +func NewHttpRedirector(client *radioai.RadioAPI, domain string) *HttpRedirector { return &HttpRedirector{ client: client, + domain: domain, } } @@ -97,10 +104,55 @@ func (h *HttpRedirector) serveSource(w http.ResponseWriter, r *http.Request) { proxy.ServeHTTP(w, r) } +func (h *HttpRedirector) serveStatusPage(w http.ResponseWriter, r *http.Request) { + nodes, _ := h.client.GetNodes() + mounts, _ := h.client.ListMounts() + ctx := struct { + Domain string + Nodes []string + Mounts []*radioai.Mount + }{h.domain, nodes, mounts} + + var buf bytes.Buffer + if err := h.template.ExecuteTemplate(&buf, "index.html", ctx); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(buf.Bytes()) +} + func (h *HttpRedirector) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method == "SOURCE" { + if r.URL.Path == "" || r.URL.Path == "/" { + h.serveStatusPage(w, r) + } else if r.Method == "SOURCE" { h.serveSource(w, r) } else { h.serveRelay(w, r) } } + +func (h *HttpRedirector) Run(addr, staticDir, templateDir string) { + h.template = template.Must( + template.ParseGlob( + filepath.Join(templateDir, "*.html"))) + + mux := http.NewServeMux() + mux.HandleFunc( + "/static/", + GZIPHandler( + http.StripPrefix( + "/static/", + http.FileServer(http.Dir(staticDir))), + nil)) + mux.Handle("/", h) + + httpServer := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + log.Printf("starting HTTP server on %s/tcp", httpServer.Addr) + log.Fatal(httpServer.ListenAndServe()) +} diff --git a/fe/static/fullbg.js b/fe/static/fullbg.js new file mode 100644 index 0000000000000000000000000000000000000000..f038380330689e829f7d77b3fe2565452144492c --- /dev/null +++ b/fe/static/fullbg.js @@ -0,0 +1,50 @@ +/** + * jQuery.fullBg + * Version 1.0 + * Copyright (c) 2010 c.bavota - http://bavotasan.com + * Dual licensed under MIT and GPL. + * Date: 02/23/2010 +**/ +(function($) { + $.fn.fullBg = function(){ + var bgImg = $(this); + + function resizeImg() { + var imgwidth = bgImg.width(); + var imgheight = bgImg.height(); + + var winwidth = $(window).width(); + var winheight = $(window).height(); + + var widthratio = winwidth / imgwidth; + var heightratio = winheight / imgheight; + + var widthdiff = heightratio * imgwidth; + var heightdiff = widthratio * imgheight; + + var newwidth, newheight; + if(heightdiff>winheight) { + newwidth = winwidth; + newheight = heightdiff; + } else { + newwidth = widthdiff; + newheight = winheight; + } + var xoffset = (newwidth - winwidth) / 2, + yoffset = (newheight - winheight) / 2; + + bgImg.css({ + width: newwidth+'px', + height: newheight+'px', + top: '-'+yoffset+'px', + left: '-'+xoffset+'px' + }); + } + resizeImg(); + $(window).resize(function() { + resizeImg(); + }); + + return this; + }; +})(jQuery) diff --git a/fe/static/radiomast_bw.jpg b/fe/static/radiomast_bw.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d6d52f22f58821f24e350832680a128db004fc43 Binary files /dev/null and b/fe/static/radiomast_bw.jpg differ diff --git a/fe/static/style.css b/fe/static/style.css new file mode 100644 index 0000000000000000000000000000000000000000..6a2dbdaa75cdc874edb07a6e44947492749bb0b7 --- /dev/null +++ b/fe/static/style.css @@ -0,0 +1,74 @@ +/* Space out content a bit */ +body { + padding-top: 80px; + padding-bottom: 20px; +} + +/* Everything gets side spacing for mobile first views */ +.header, +.mainitems, +.footer { + padding-left: 15px; + padding-right: 15px; +} + +/* Custom page header */ +.header { + border-bottom: 1px solid #e5e5e5; +} +/* Make the masthead heading the same height as the navigation */ +.header h3 { + margin-top: 0; + margin-bottom: 0; + line-height: 40px; + padding-bottom: 19px; +} + +/* Custom page footer */ +.footer { + padding-top: 19px; + color: #777; + border-top: 1px solid #e5e5e5; +} + +/* Customize container */ +@media (min-width: 768px) { + .container { + max-width: 730px; + } +} +.container-narrow > hr { + margin: 30px 0; +} + +.mainitems { + margin: 40px 0; +} +.mainitems p + h4 { + margin-top: 28px; +} + +/* Responsive: Portrait tablets and up */ +@media screen and (min-width: 768px) { + /* Remove the padding we set earlier */ + .header, + .mainitems, + .footer { + padding-left: 0; + padding-right: 0; + } + /* Space out the masthead */ + .header { + margin-bottom: 30px; + } +} + +#bgImg { + display: none; + position: fixed; + top: 0; + left: 0; + overflow: hidden; + z-index: -100; + opacity: 0.3; +} \ No newline at end of file diff --git a/fe/templates/index.html b/fe/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..973fc678b0da52d51b8e26b9bae649fdb12654f3 --- /dev/null +++ b/fe/templates/index.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html> + <head> + <title>stream.{{.Domain}}</title> + <link rel="stylesheet" + href="//netdna.bootstrapcdn.com/bootstrap/3.0.2/css/bootstrap.min.css"> + <link rel="stylesheet" href="/static/style.css"> + <link rel="shortcut icon" href="/static/favicon.png"> + + <script src="//ajax.googleapis.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script> + <script src="/static/fullbg.js"></script> + </head> + <body> + + <div class="container"> + <div class="page-header"> + <h1>{{.Domain}} + <small> + streaming network + </small> + </h1> + </div> + + <div class="row mainitems"> + <div class="col-lg-6"> + <h4>Streams</h4> + {{$domain := .Domain}} + {{range .Mounts}} + <p> + <a href="http://stream.{{$domain}}{{.Name}}.m3u">{{.Name}}</a> + </p> + {{end}} + </div> + + <div class="col-lg-6"> + <h4>Nodes</h4> + {{range .Nodes}} + <p>{{.}}</p> + {{end}} + </div> + </div> + + <div class="footer"> + powered by + <a href="https://git.autistici.org/public/ale/radioai"> + radioai 0.1 + </a> + </div> + </div> + + <img src="/static/radiomast_bw.jpg" id="bgImg"> + + <script type="text/javascript"> + $(function() { + $('#bgImg').fullBg().show(); + }); + </script> + </body> +</html>