// SPDX-License-Identifier: AGPL-3.0-or-later
package web
import (
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"io/fs"
"net/http"
"path"
"sort"
"strings"
)
//go:embed static
var staticFS embed.FS
// StaticPrefix is where the embedded assets are served.
const StaticPrefix = "/-/static/"
// Assets are the URLs of the assets a page loads. Their names carry a hash of
// the content, so they can be cached for ever.
type Assets struct {
CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string
// Manifest is the web app manifest (install on a phone's home screen),
// AppleIcon the home screen icon iOS reads from a link instead.
Manifest, AppleIcon string
// Font is the UI font, preloaded by every page.
Font string
// Rules is the speculation rules file named by the Speculation-Rules header.
Rules string
}
// asset is one embedded file under its content-hashed name.
type asset struct {
body []byte
typ string
// gz is the gzip form of body, compressed once at load; nil when the type
// is already compressed or gzip does not make the body smaller.
gz []byte
}
// assetSet is the embedded files by hashed URL path (under StaticPrefix).
type assetSet struct {
byName map[string]asset
urls Assets
sw serviceWorker
}
// scripts are the JavaScript files of the page, in embedded static/js: the
// file to serve, as it is named in Assets.
var scripts = []struct {
file string
set func(*Assets, string)
}{
{"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
{"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
{"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
{"ermite.js", func(a *Assets, u string) { a.App = u }},
{"files.js", func(a *Assets, u string) { a.Files = u }},
{"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
{"log.js", func(a *Assets, u string) { a.Log = u }},
{"mr.js", func(a *Assets, u string) { a.MR = u }},
}
// sheetAssets are the files the stylesheet references as url(
/):
// fonts and icons (pages hold no inline SVG; icons are CSS masks).
var sheetAssets = []struct{ glob, typ string }{
{"static/fonts/*.woff2", "font/woff2"},
{"static/icons/*.svg", "image/svg+xml"},
}
// loadAssets hashes the embedded files. The stylesheet is the concatenation
// of static/css/*.css in name order; its url(/) references become
// the hashed URLs of the files of sheetAssets.
func loadAssets() (*assetSet, error) {
s := &assetSet{byName: map[string]asset{}}
put := func(dir, file string, body []byte, typ string) string {
sum := sha256.Sum256(body)
ext := path.Ext(file)
name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
a := asset{body: body, typ: typ}
if compressible(typ) {
if z, err := gzipBest(body); err == nil && len(z) < len(body) {
a.gz = z
}
}
s.byName[name] = a
return StaticPrefix + name
}
var refs []string
for _, sa := range sheetAssets {
files, err := fs.Glob(staticFS, sa.glob)
if err != nil {
return nil, err
}
for _, f := range files {
b, err := staticFS.ReadFile(f)
if err != nil {
return nil, err
}
dir := path.Base(path.Dir(f))
u := put(dir, path.Base(f), b, sa.typ)
refs = append(refs, "url("+dir+"/"+path.Base(f)+")", "url("+u+")")
if f == "static/fonts/atkinson-next.woff2" {
s.urls.Font = u
}
}
}
files, err := fs.Glob(staticFS, "static/css/*.css")
if err != nil {
return nil, err
}
sort.Strings(files)
var css strings.Builder
for _, f := range files {
b, err := staticFS.ReadFile(f)
if err != nil {
return nil, err
}
css.WriteString("/* " + path.Base(f) + " */\n")
css.Write(b)
}
if s.urls.Font == "" {
return nil, errors.New("web: the UI font static/fonts/atkinson-next.woff2 is missing")
}
sheet := strings.NewReplacer(refs...).Replace(css.String())
s.urls.CSS = put("css", "ermite.css", []byte(sheet), "text/css; charset=utf-8")
rules, err := staticFS.ReadFile("static/rules.json")
if err != nil {
return nil, err
}
s.urls.Rules = put("rules", "rules.json", rules, "application/speculationrules+json")
for _, sc := range scripts {
b, err := staticFS.ReadFile("static/js/" + sc.file)
if err != nil {
return nil, err
}
sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8"))
}
// The favicon is the brand picture of the header.
icon, err := staticFS.ReadFile("static/icons/brand.svg")
if err != nil {
return nil, err
}
s.urls.Icon = put("img", "icon.svg", icon, "image/svg+xml")
if err := s.loadApp(put); err != nil {
return nil, err
}
sw, err := loadServiceWorker()
if err != nil {
return nil, err
}
s.sw = sw
return s, nil
}
// appIcons are the PNG icons of the installed app (static/app), in the
// manifest; purpose "maskable" has the picture inside the safe zone on a
// full-bleed background, for launchers that crop to a circle or a squircle.
var appIcons = []struct{ file, sizes, purpose string }{
{"icon-192.png", "192x192", "any"},
{"icon-512.png", "512x512", "any"},
{"icon-maskable-512.png", "512x512", "maskable"},
}
// webManifest is the shape of the web app manifest: what a browser asks for to
// offer "install" / "add to home screen", and the id and description of the
// richer install dialog.
type webManifest struct {
// ID is the identity of the app, independent of the (hashed) URL of the
// manifest and of start_url.
ID string `json:"id"`
Name string `json:"name"`
ShortName string `json:"short_name"`
Description string `json:"description"`
StartURL string `json:"start_url"`
Scope string `json:"scope"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Icons []manifestIcon `json:"icons"`
}
type manifestIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
}
// appColor is the dark canvas: the splash screen and the bar of the installed
// app (the pages follow the system theme; the manifest takes one colour).
const appColor = "#050505"
// loadApp adds the app icons and the manifest, which names them by their
// hashed URLs.
func (s *assetSet) loadApp(put func(dir, name string, b []byte, typ string) string) error {
m := webManifest{
ID: "/", Name: "ermite", ShortName: "ermite", StartURL: "/", Scope: "/", Display: "standalone",
Description: "Self-hosted Git forge: browse code, review merge requests and follow CI.",
BackgroundColor: appColor, ThemeColor: appColor,
}
for _, ic := range appIcons {
b, err := staticFS.ReadFile("static/app/" + ic.file)
if err != nil {
return err
}
m.Icons = append(m.Icons, manifestIcon{Src: put("app", ic.file, b, "image/png"), Sizes: ic.sizes, Type: "image/png", Purpose: ic.purpose})
}
apple, err := staticFS.ReadFile("static/app/apple-touch-icon.png")
if err != nil {
return err
}
s.urls.AppleIcon = put("app", "apple-touch-icon.png", apple, "image/png")
b, err := json.Marshal(m)
if err != nil {
return err
}
s.urls.Manifest = put("app", "manifest.webmanifest", b, "application/manifest+json")
return nil
}
// serve answers GET /-/static/. A name that does not match a
// current asset is a 404, so a stale hash never serves the wrong content.
func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)]
if !ok {
http.NotFound(w, r)
return
}
h := w.Header()
h.Set("Content-Type", a.typ)
h.Set("Cache-Control", "public, max-age=31536000, immutable")
body := a.body
if a.gz != nil {
addVary(h, "Accept-Encoding")
if acceptsGzip(r.Header) {
body = a.gz
h.Set("Content-Encoding", "gzip")
}
}
h.Set("Content-Length", itoa(len(body)))
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
// compressible tells whether a content type is worth gzip: text and
// structured text. Fonts (woff2) and raster images are compressed already.
func compressible(typ string) bool {
mt, _, _ := strings.Cut(typ, ";")
mt = strings.TrimSpace(strings.ToLower(mt))
return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" ||
mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json")
}