Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func Parse() {
printErrorAndExit(fmt.Errorf("unable to generate stylesheets bundle: %v", err))
}

if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn()); err != nil {
if err := static.GenerateJavascriptBundles(config.Opts.WebAuthn(), config.Opts.BasePath()); err != nil {
printErrorAndExit(fmt.Errorf("unable to generate javascript bundle: %v", err))
}

Expand Down
80 changes: 72 additions & 8 deletions internal/ui/static/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,45 @@ package static // import "miniflux.app/v2/internal/ui/static"

import (
"bytes"
"compress/gzip"
"embed"
"fmt"
"log/slog"
"slices"
"strings"

"miniflux.app/v2/internal/crypto"

"github.com/andybalholm/brotli"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/js"
"github.com/tdewolff/minify/v2/svg"
)

const licensePrefix = "//@license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0\n"
const licenseSuffix = "\n//@license-end"

type asset struct {
Data []byte
Checksum string
Data []byte
Checksum string
BrotliData []byte
GzipData []byte
}

// Negotiate selects the best pre-compressed representation of the
// asset based on the Accept-Encoding request header value. It returns
// the bytes to write and the Content-Encoding value ("br", "gzip", or
// "" for identity).
func (a asset) Negotiate(acceptEncoding string) (body []byte, encoding string) {
Copy link
Copy Markdown
Contributor

@gudvinr gudvinr Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That'd lead to invalid results unfortunately.

Accept-Encoding has very specific notation of content.

  • Yours will accept gobrrrrr
  • As well as go br
  • And pew-gzip-pew
  • And so on

Also quality value is omitted so for gzip, br;q=0 you'd still return br although that explicitly means client requested no brotli.


I'd not chase 100% correctness tbh. Most (if not all) browser would send plain list of encoding. Compared to Accept-Language (which actually uses q-values) iteration would be dead simple:

for e := strings.SplitSeq(acceptEncoding) {
  e = strings.TrimSpace(e)
  if i := strings.IndexByte(e, ';'); i > -1 {
    e = e[:i] // just in case browsers become smart. not exactly right but whatever
  }

  switch e {
  case "br": ...
  case "gzip": ...
  case "identity": // return raw
  }
}

// fallback to raw

switch {
case a.BrotliData != nil && strings.Contains(acceptEncoding, "br"):
return a.BrotliData, "br"
case a.GzipData != nil && strings.Contains(acceptEncoding, "gzip"):
return a.GzipData, "gzip"
default:
return a.Data, ""
}
}

// Static assets.
Expand All @@ -39,6 +62,24 @@ var stylesheetFiles embed.FS
//go:embed js/*.js
var javascriptFiles embed.FS

// precompress produces brotli and gzip compressed variants of data.
// Best compression levels are used because this only runs once at
// startup; the resulting byte slices are served directly on every
// request, avoiding any per-request compression work.
func precompress(data []byte) (brotliData, gzipData []byte) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With FS per-encoding I'd maybe run that in separate goroutine to avoid delaying startup and at the very end just swap whole FS with one that contains compressed data.

var br bytes.Buffer
bw := brotli.NewWriterV2(&br, brotli.BestCompression)
bw.Write(data)
bw.Close()

var gz bytes.Buffer
gw, _ := gzip.NewWriterLevel(&gz, gzip.BestCompression)
gw.Write(data)
gw.Close()

return br.Bytes(), gz.Bytes()
}

func GenerateBinaryBundles() error {
dirEntries, err := binaryFiles.ReadDir("bin")
if err != nil {
Expand Down Expand Up @@ -66,10 +107,16 @@ func GenerateBinaryBundles() error {
}
}

BinaryBundles[name] = asset{
a := asset{
Data: data,
Checksum: crypto.HashFromBytes(data),
}

if strings.HasSuffix(name, ".svg") {
a.BrotliData, a.GzipData = precompress(data)
}

BinaryBundles[name] = a
}

return nil
Expand Down Expand Up @@ -108,17 +155,21 @@ func GenerateStylesheetsBundles() error {
return err
}

br, gz := precompress(minifiedData)
StylesheetBundles[bundleName+".css"] = asset{
Data: minifiedData,
Checksum: crypto.HashFromBytes(minifiedData),
Data: minifiedData,
Checksum: crypto.HashFromBytes(minifiedData),
BrotliData: br,
GzipData: gz,
}
}

return nil
}

// GenerateJavascriptBundles creates JS bundles.
func GenerateJavascriptBundles(webauthnEnabled bool) error {
// basePath is prepended to route paths embedded in the service worker bundle.
func GenerateJavascriptBundles(webauthnEnabled bool, basePath string) error {
var bundles = map[string][]string{
"app": {
"js/touch_handler.js",
Expand Down Expand Up @@ -158,9 +209,22 @@ func GenerateJavascriptBundles(webauthnEnabled bool) error {
return err
}

var buf bytes.Buffer
buf.WriteString(licensePrefix)
if bundleName == "service-worker" {
fmt.Fprintf(&buf, "const OFFLINE_URL=%q;", basePath+"/offline")
}
buf.Write(minifiedData)
buf.WriteString(licenseSuffix)

contents := buf.Bytes()
br, gz := precompress(contents)

JavascriptBundles[bundleName+".js"] = asset{
Data: minifiedData,
Checksum: crypto.HashFromBytes(minifiedData),
Data: contents,
Checksum: crypto.HashFromBytes(contents),
BrotliData: br,
GzipData: gz,
}
}

Expand Down
13 changes: 9 additions & 4 deletions internal/ui/static_app_icon.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,26 @@ import (

func (h *handler) showAppIcon(w http.ResponseWriter, r *http.Request) {
filename := request.RouteStringParam(r, "filename")
value, ok := static.BinaryBundles[filename]
bundle, ok := static.BinaryBundles[filename]
if !ok {
response.HTMLNotFound(w, r)
return
}

response.NewBuilder(w, r).WithCaching(value.Checksum, 72*time.Hour, func(b *response.Builder) {
response.NewBuilder(w, r).WithCaching(bundle.Checksum, 72*time.Hour, func(b *response.Builder) {
body, encoding := bundle.Negotiate(r.Header.Get("Accept-Encoding"))
b.WithoutCompression() // No need to compress already-compressed data.
if encoding != "" {
b.WithHeader("Content-Encoding", encoding)
b.WithHeader("Vary", "Accept-Encoding")
}
switch filepath.Ext(filename) {
case ".png":
b.WithoutCompression()
b.WithHeader("Content-Type", "image/png")
case ".svg":
b.WithHeader("Content-Type", "image/svg+xml")
}
b.WithBodyAsBytes(value.Data)
b.WithBodyAsBytes(body)
b.Write()
})
}
29 changes: 9 additions & 20 deletions internal/ui/static_javascript.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,30 @@
package ui // import "miniflux.app/v2/internal/ui"

import (
"fmt"
"net/http"
"strings"
"time"

"miniflux.app/v2/internal/http/response"

"miniflux.app/v2/internal/ui/static"
)

const licensePrefix = "//@license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0\n"
const licenseSuffix = "\n//@license-end"

func (h *handler) showJavascript(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
javascriptBundle, found := static.JavascriptBundles[filename]
bundle, found := static.JavascriptBundles[filename]
if !found {
response.HTMLNotFound(w, r)
return
}

response.NewBuilder(w, r).WithCaching(javascriptBundle.Checksum, 48*time.Hour, func(b *response.Builder) {
contents := javascriptBundle.Data

if filename == "service-worker.js" {
variables := fmt.Sprintf(`const OFFLINE_URL=%q;`, h.routePath("/offline"))
contents = append([]byte(variables), contents...)
}

// cloning the prefix since `append` mutates its first argument
contents = append([]byte(strings.Clone(licensePrefix)), contents...)
contents = append(contents, []byte(licenseSuffix)...)

response.NewBuilder(w, r).WithCaching(bundle.Checksum, 48*time.Hour, func(b *response.Builder) {
body, encoding := bundle.Negotiate(r.Header.Get("Accept-Encoding"))
b.WithoutCompression()
b.WithHeader("Content-Type", "text/javascript; charset=utf-8")
b.WithBodyAsBytes(contents)
b.WithHeader("Vary", "Accept-Encoding")
if encoding != "" {
b.WithHeader("Content-Encoding", encoding)
}
b.WithBodyAsBytes(body)
b.Write()
})
}
9 changes: 7 additions & 2 deletions internal/ui/static_stylesheet.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"time"

"miniflux.app/v2/internal/http/response"

"miniflux.app/v2/internal/ui/static"
)

Expand All @@ -20,8 +19,14 @@ func (h *handler) showStylesheet(w http.ResponseWriter, r *http.Request) {
}

response.NewBuilder(w, r).WithCaching(stylesheetBundle.Checksum, 48*time.Hour, func(b *response.Builder) {
body, encoding := stylesheetBundle.Negotiate(r.Header.Get("Accept-Encoding"))
b.WithoutCompression()
b.WithHeader("Content-Type", "text/css; charset=utf-8")
b.WithBodyAsBytes(stylesheetBundle.Data)
b.WithHeader("Vary", "Accept-Encoding")
if encoding != "" {
b.WithHeader("Content-Encoding", encoding)
}
b.WithBodyAsBytes(body)
b.Write()
})
}
Loading