Files
silo-server/internal/server/frontend.go
T
5afe56cfc0 feat(jellycompat): add runtime-managed Jellyfin Web compatibility (#77)
* feat(jellycompat): install web assets at runtime

* fix(jellycompat): recover stale web operation locks

* fix(jellycompat): harden web component management

* feat(admin): refine compat settings and restart status

* chore(dev): add hot-reload docker compose stack

* fix(dev): include npm in hot-reload backend

* feat(admin): refine Jellyfin compatibility settings

* feat(settings): improve jellyfin proxy summary

* feat(settings): improve jellyfin web controls

* fix(settings): update jellyfin web removal status

* fix(settings): enable jellyfin web after install

* feat(jellycompat): auto-select web ui version

* test(api): update rate limit handler setup

* feat(jellycompat): refine web ui install onboarding

* fix(jellycompat): address web ui install review issues

* fix(onboarding): mirror jellyfin api runtime status

* fix(admin): remove global restart banner

* fix(settings): gate restart required tracking

* fix(jellyfin): ignore live settings for restart status

* fix(jellyfin): avoid restart for live compat settings

* fix(subtitles): normalize AI language codes

* fix(catalog): support partial title search tokens

* feat(branding): add white-label customization

* Add push relay engineering plan

- Document relay API contracts, APNs/FCM behavior, auth, storage, and ops
- Capture implementation plan, provider references, decisions, and README

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-15 09:34:08 -04:00

152 lines
6.0 KiB
Go

package server
import (
"io/fs"
"net/http"
"strings"
"github.com/Silo-Server/silo-server/internal/branding"
)
// WebDistFS holds the embedded frontend build output.
// When nil, FrontendHandler returns a placeholder response.
var WebDistFS fs.FS
// Branding supplies white-label customization (server name, favicon, manifest)
// to the SPA shell. When nil, the frontend is served exactly as built.
var Branding *branding.Service
// frontendContentSecurityPolicy is served with every SPA HTML response.
//
// SECURITY: this policy is the primary mitigation for malicious ebook content.
// The in-app reader (foliate-js) renders book chapters in same-origin blob:
// iframes with allow-scripts (required to work around a WebKit bug), and
// blob:/srcdoc documents inherit the embedding document's CSP. With
// script-src 'self', scripts embedded in an EPUB (blob:/inline/data: sources)
// cannot execute, so a hostile book cannot read localStorage tokens or call
// the API. Do not add 'unsafe-inline', 'unsafe-eval', blob:, or data: to
// script-src without revisiting that threat model.
//
// Allowances beyond 'self' exist for concrete app needs:
// - script-src 'wasm-unsafe-eval': JASSUB (libass) subtitle rendering and
// node-unrar-js CBR extraction compile WebAssembly.
// - style-src blob: and 'unsafe-inline': foliate-js loads EPUB stylesheets
// via blob: URLs; the app uses inline style attributes. Google Fonts CSS
// is linked from index.html.
// - img-src/media-src http(s): artwork can come from TMDB/TVDB/S3 public
// URLs, and stream URLs may point at standalone proxy/transcode workers
// on another origin (proxy public_url, plain http on LANs).
// - connect-src http(s)/ws(s): realtime session hub WebSockets, browser-side
// Plex auth (plex.tv), and HLS fetches against standalone worker origins.
// - font-src blob: data: plus fonts.gstatic.com for Google Fonts; reader
// book fonts load from blob: URLs.
const frontendContentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline' blob: https://fonts.googleapis.com; " +
"img-src 'self' blob: data: http: https:; " +
"font-src 'self' blob: data: https://fonts.gstatic.com; " +
"media-src 'self' blob: http: https:; " +
"connect-src 'self' ws: wss: http: https:; " +
"worker-src 'self' blob:; " +
"frame-src 'self' blob:; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
// FrontendHandler returns an http.Handler that serves the embedded SPA.
// It serves static files from WebDistFS and falls back to index.html for
// SPA routing (any path that doesn't match a file).
func FrontendHandler() http.Handler {
if WebDistFS == nil {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Frontend not built. Run: cd web && bun run build"))
})
}
fileServer := http.FileServer(http.FS(WebDistFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
path := r.URL.Path
// Dynamic branding endpoints must be handled before the static file
// server, which would otherwise serve the bundled defaults shadowing
// them. Both fall through to the static asset when no override applies.
if Branding != nil {
switch path {
case "/site.webmanifest":
serveDynamicManifest(w, r)
return
case "/favicon.ico":
if serveCustomFavicon(w, r) {
return
}
}
}
// Try to serve the file directly. index.html is excluded so the SPA
// HTML always goes through the fallback below and carries the CSP.
if path != "/" && path != "/index.html" && !strings.HasSuffix(path, "/") {
if f, err := WebDistFS.Open(strings.TrimPrefix(path, "/")); err == nil {
f.Close()
fileServer.ServeHTTP(w, r)
return
}
}
// SPA fallback: serve index.html
indexBytes, err := fs.ReadFile(WebDistFS, "index.html")
if err != nil {
http.Error(w, "index.html not found", http.StatusInternalServerError)
return
}
if Branding != nil {
indexBytes = branding.RenderIndexHTML(indexBytes, Branding.Load(r.Context()))
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", frontendContentSecurityPolicy)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(indexBytes)
})
}
// serveDynamicManifest writes the branding-aware web app manifest.
func serveDynamicManifest(w http.ResponseWriter, r *http.Request) {
body := branding.RenderManifest(Branding.Load(r.Context()))
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
// serveCustomFavicon serves the admin-uploaded favicon at /favicon.ico when one
// is configured, so direct requests (browsers, crawlers) get the branded icon.
// Returns false when there is no custom favicon, letting the caller fall through
// to the bundled static file.
func serveCustomFavicon(w http.ResponseWriter, r *http.Request) bool {
data, contentType, ref, err := Branding.GetAsset(r.Context(), branding.KindFavicon)
if err != nil {
return false
}
// X-Content-Type-Options is already set on the response by the caller. The
// favicon may be an admin-uploaded SVG; harden it against script execution
// on direct navigation (stored-XSS defense), matching the API asset route.
etag := `"` + ref + `"`
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Security-Policy", branding.AssetContentSecurityPolicy)
w.Header().Set("ETag", etag)
// Stable path (no content hash in the URL), so revalidate rather than cache
// long-lived; the ETag lets browsers skip the body when unchanged.
w.Header().Set("Cache-Control", "public, max-age=300")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return true
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
return true
}