* 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>
238 lines
6.4 KiB
Go
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
|
|
}
|