Files
silo-server/internal/jellycompat/web.go
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

238 lines
6.4 KiB
Go

package jellycompat
import (
"context"
"io/fs"
"net/http"
"os"
"path"
"strconv"
"strings"
)
func newCompatWebFSFromDirectory(root string) (fs.FS, error) {
if strings.TrimSpace(root) == "" {
return nil, nil
}
if _, err := validateWebComponentDirectory(root); err != nil {
return nil, err
}
webFS := os.DirFS(root)
return webFS, nil
}
func newCompatWebHandler(webFS fs.FS, version string) http.Handler {
fileServer := http.FileServer(http.FS(webFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
cleanPath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
relPath := strings.TrimPrefix(cleanPath, "/")
switch relPath {
case "", ".":
relPath = "index.html"
}
if version != "" {
w.Header().Set("X-Silo-Jellyfin-Web-Version", version)
}
// Block content sniffing on everything we serve here. A CSP is
// intentionally NOT set: the vendored jellyfin-web bundle relies on
// inline scripts and would break under any meaningful policy.
w.Header().Set("X-Content-Type-Options", "nosniff")
if fileExists(webFS, relPath) {
if relPath == "index.html" {
indexBytes, err := fs.ReadFile(webFS, "index.html")
if err != nil {
http.Error(w, "index.html not found", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(indexBytes)
}
return
}
if info, err := fs.Stat(webFS, relPath); err == nil && info.IsDir() && !strings.HasSuffix(r.URL.Path, "/") {
target := path.Clean("/web/" + relPath)
http.Redirect(w, r, target+"/", http.StatusMovedPermanently)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
req := r.Clone(r.Context())
req.URL.Path = "/" + strings.TrimPrefix(r.URL.Path, "/")
fileServer.ServeHTTP(w, req)
return
}
if shouldServeCompatWebIndex(relPath) {
indexBytes, err := fs.ReadFile(webFS, "index.html")
if err != nil {
http.Error(w, "index.html not found", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(indexBytes)
}
return
}
http.NotFound(w, r)
})
}
func newDynamicCompatWebHandler(deps Dependencies) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
webFS, version, err := resolveCompatWebFS(ctx, deps)
if err != nil || webFS == nil {
if err == nil && !compatWebEnabled(ctx, deps) && compatWebAssetsInstalled(ctx, deps) {
http.Error(w, compatWebDisabledMessage(ctx, deps), http.StatusNotFound)
return
}
http.Error(w, "Jellyfin Web UI assets are not installed", http.StatusNotFound)
return
}
newCompatWebHandler(webFS, version).ServeHTTP(w, r)
})
}
func shouldServeCompatWebIndex(relPath string) bool {
if relPath == "" || relPath == "." || relPath == "index.html" {
return true
}
base := path.Base(relPath)
return !strings.Contains(base, ".")
}
func fileExists(fsys fs.FS, name string) bool {
if _, err := fs.Stat(fsys, name); err == nil {
return true
}
return false
}
func resolveCompatWebFS(ctx context.Context, deps Dependencies) (fs.FS, string, error) {
if !compatWebEnabled(ctx, deps) {
return nil, "", nil
}
if deps.WebFS != nil {
if _, err := fs.Stat(deps.WebFS, "index.html"); err != nil {
return nil, "", err
}
return deps.WebFS, compatWebVersion(ctx, deps), nil
}
if deps.Config == nil {
return nil, "", nil
}
root := compatWebDir(ctx, deps)
if root == "" {
return nil, "", nil
}
webFS, err := newCompatWebFSFromDirectory(root)
if err != nil {
return nil, "", err
}
return webFS, compatWebVersion(ctx, deps), nil
}
func compatWebEnabled(ctx context.Context, deps Dependencies) bool {
proxyEnabled, webEnabled := compatWebEnablement(ctx, deps)
return proxyEnabled && webEnabled
}
func compatWebDisabledMessage(ctx context.Context, deps Dependencies) string {
proxyEnabled, webEnabled := compatWebEnablement(ctx, deps)
switch {
case !proxyEnabled:
return "Jellyfin Web UI is disabled because the Jellyfin compatibility proxy is disabled"
case !webEnabled:
return "Jellyfin Web UI is disabled in Silo settings"
default:
return "Jellyfin Web UI is disabled"
}
}
func compatWebEnablement(ctx context.Context, deps Dependencies) (bool, bool) {
proxyEnabled := deps.Config == nil || deps.Config.JellyfinCompat.Enabled
webEnabled := true
if deps.Config != nil {
webEnabled = deps.Config.JellyfinCompat.WebEnabled
}
if deps.SettingsRepo != nil {
if value, _ := deps.SettingsRepo.Get(ctx, "jellyfin_compat.enabled"); strings.TrimSpace(value) != "" {
if parsed, err := strconv.ParseBool(strings.TrimSpace(value)); err == nil {
proxyEnabled = parsed
}
}
if value, _ := deps.SettingsRepo.Get(ctx, "jellyfin_compat.web_enabled"); strings.TrimSpace(value) != "" {
if parsed, err := strconv.ParseBool(strings.TrimSpace(value)); err == nil {
webEnabled = parsed
}
}
}
return proxyEnabled, webEnabled
}
func compatWebAssetsInstalled(ctx context.Context, deps Dependencies) bool {
if deps.WebFS != nil {
_, err := fs.Stat(deps.WebFS, "index.html")
return err == nil
}
if deps.Config == nil {
return false
}
root := compatWebDir(ctx, deps)
if root == "" {
return false
}
_, err := validateWebComponentDirectory(root)
return err == nil
}
func compatWebDir(ctx context.Context, deps Dependencies) string {
root := ""
if deps.SettingsRepo != nil {
if value, _ := deps.SettingsRepo.Get(ctx, "jellyfin_compat.web_install_dir"); strings.TrimSpace(value) != "" {
root = strings.TrimSpace(value)
}
}
if root == "" {
if deps.Config == nil {
return ""
}
root = DefaultWebInstallRoot(deps.Config)
}
root, err := normalizeWebInstallRoot(root)
if err != nil {
return ""
}
return ManagedWebInstallPath(root)
}
func compatWebVersion(ctx context.Context, deps Dependencies) string {
if deps.SettingsRepo != nil {
if value, _ := deps.SettingsRepo.Get(ctx, "jellyfin_compat.web_version"); strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
if deps.Config == nil {
return ""
}
return deps.Config.JellyfinCompat.WebVersion
}