From 85ab0e8c2e30db17109a8e7b62afe20b36491e57 Mon Sep 17 00:00:00 2001 From: 0xv1n Date: Tue, 28 Jul 2026 22:41:34 -0700 Subject: [PATCH 1/2] disable web UI login when no credentials are set When UI_USERNAME and UI_PASSWORD are both empty the login screen was pure friction: NewAuthStore hashed the empty string and CompareCreds accepted empty input, so any visitor signed in by clicking the button with both fields blank. Auth is now skipped entirely in that case. RequireAuth returns the handler unwrapped, /api/ui/auth/status reports auth_disabled so the frontend routes straight into the app, and the server logs a warning at startup that it is running unauthenticated. The Settings header shows an "Auth disabled" chip in place of "Sign out". Behaviour is unchanged when credentials are configured. CSRF is unaffected: SessionManager.Handle verifies tokens on every mutating request independently of auth. Refs #134 --- src/web/backend/auth/auth.go | 14 ++++++++++ src/web/backend/auth/handlers.go | 29 +++++++++++++++++++- src/web/backend/server.go | 3 ++ src/web/frontend/src/App.jsx | 7 +++-- src/web/frontend/src/components/Settings.jsx | 23 +++++++++++----- src/web/frontend/src/lib/api.js | 4 ++- 6 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/web/backend/auth/auth.go b/src/web/backend/auth/auth.go index b763fbc3..47bfeeec 100644 --- a/src/web/backend/auth/auth.go +++ b/src/web/backend/auth/auth.go @@ -13,10 +13,20 @@ import ( type AuthStore struct { Username string Hash string + Disabled bool // true when no credentials are configured; all routes become public sessionManager *SessionManager } func NewAuthStore(user, password string, sessionManager *SessionManager) *AuthStore{ + // No credentials configured at all: run the UI unauthenticated instead of + // showing a login screen that accepts empty input anyway. + if user == "" && password == "" { + return &AuthStore{ + Disabled: true, + sessionManager: sessionManager, + } + } + hashPass, err := hashPassword(password) if err != nil { panic("failed to hash password") @@ -38,6 +48,10 @@ func (a *AuthStore) CompareCreds(formUser, formPass string) bool { } func (a *AuthStore) RequireAuth(next http.Handler) http.Handler { + if a.Disabled { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess := a.sessionManager.GetSession(r) diff --git a/src/web/backend/auth/handlers.go b/src/web/backend/auth/handlers.go index 83568fdd..029b9fa0 100644 --- a/src/web/backend/auth/handlers.go +++ b/src/web/backend/auth/handlers.go @@ -7,13 +7,28 @@ import ( ) func (a *AuthStore) HandleAuthStatus(w http.ResponseWriter, r *http.Request) { + if a.Disabled { + writeAuthStatus(w, true, true) + return + } + sess := a.sessionManager.GetSession(r) auth, _ := sess.Get("authenticated").(bool) if !auth { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - w.WriteHeader(http.StatusOK) + writeAuthStatus(w, true, false) +} + +func writeAuthStatus(w http.ResponseWriter, authenticated, disabled bool) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]bool{ + "authenticated": authenticated, + "auth_disabled": disabled, + }); err != nil { + slog.Error("failed encoding auth status to http", "msg", err.Error()) + } } func (a *AuthStore) HandleLogin(w http.ResponseWriter, r *http.Request) { @@ -23,6 +38,13 @@ func (a *AuthStore) HandleLogin(w http.ResponseWriter, r *http.Request) { return } + // Nothing to authenticate against. Accept, so a cached frontend that still + // renders the login form does not get a confusing 401 from the empty hash. + if a.Disabled { + w.WriteHeader(http.StatusOK) + return + } + if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return @@ -49,6 +71,11 @@ func (a *AuthStore) HandleLogin(w http.ResponseWriter, r *http.Request) { } func (a *AuthStore) HandleLogout(w http.ResponseWriter, r *http.Request) { + if a.Disabled { + w.WriteHeader(http.StatusOK) + return + } + sess := a.sessionManager.GetSession(r) sess.Delete("authenticated") sess.Delete("username") diff --git a/src/web/backend/server.go b/src/web/backend/server.go index bcd2c3c9..9da7747e 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -89,6 +89,9 @@ func (s *Server) Start() error { if _, err := os.Stat(coversDir); os.IsNotExist(err) { s.customPlaylist.PrefetchCovers() } + if s.authStore.Disabled { + slog.Warn("web UI authentication is DISABLED - UI_USERNAME and UI_PASSWORD are not set; anyone who can reach this address has full control over Explo") + } slog.Info("Explo web UI started", "addr", s.server.Addr) go checkForUpdate() return s.server.ListenAndServe() diff --git a/src/web/frontend/src/App.jsx b/src/web/frontend/src/App.jsx index c4819e3b..0d69946c 100644 --- a/src/web/frontend/src/App.jsx +++ b/src/web/frontend/src/App.jsx @@ -12,14 +12,16 @@ export default function App() { const [bgUrl, setBgUrl] = useState(null) const [bgLoaded, setBgLoaded] = useState(false) const [fadingOut, setFadingOut] = useState(false) + const [authDisabled, setAuthDisabled] = useState(false) useEffect(() => { Promise.all([ checkAuth(), fetchSetupStatus(), - ]).then(([authed, status]) => { + ]).then(([{ authenticated, authDisabled }, status]) => { setIsFirstTime(status ? !status.wizard_complete : false) - if (authed) { + setAuthDisabled(authDisabled) + if (authenticated) { handleLoginSuccess({ fromLogin: false }) } else { setView('login') @@ -83,6 +85,7 @@ export default function App() { return ( setView('wizard')} onLogout={() => logout().then(() => setView('login'))} /> diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index e65fffca..da645a4a 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -957,7 +957,7 @@ function LogsSection() { // Module-level cache so the picked cover survives component remounts. let _bgCoverCache = null -export default function Settings({ onWizard, onLogout }) { +export default function Settings({ authDisabled, onWizard, onLogout }) { const [activeTab, setActiveTab] = useState('run') const [bgCover, setBgCover] = useState(_bgCoverCache) @@ -1012,12 +1012,21 @@ export default function Settings({ onWizard, onLogout }) { - + {authDisabled ? ( + + Auth disabled + + ) : ( + + )} {activeTab === 'run' && } diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index cc3455a7..4fc349eb 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -28,7 +28,9 @@ async function apiFetch(url, options = {}) { export async function checkAuth() { const res = await fetch('/api/ui/auth/status', { credentials: 'include' }) - return res.ok + // Older builds return an empty body, so tolerate a failed parse. + const data = await res.json().catch(() => ({})) + return { authenticated: res.ok, authDisabled: !!data.auth_disabled } } export async function login(username, password) { From bc3cb30e550f3f2eeae9ed676750a1628121d606 Mon Sep 17 00:00:00 2001 From: 0xv1n Date: Tue, 28 Jul 2026 22:47:19 -0700 Subject: [PATCH 2/2] force LF endings on shell scripts On a Windows checkout with core.autocrlf=true, docker/start.sh gets CRLF endings. The shebang then carries a trailing carriage return and the built image dies immediately with: exec /start.sh: no such file or directory Pin *.sh to LF so the container entrypoint survives a Windows build. --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ab0bb21d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell scripts run inside Linux containers. On a Windows checkout with +# core.autocrlf=true they would otherwise get CRLF endings, and a CRLF shebang +# makes the image fail at startup with: +# exec /start.sh: no such file or directory +*.sh text eol=lf