diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ab0bb21 --- /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 diff --git a/src/web/backend/auth/auth.go b/src/web/backend/auth/auth.go index b763fbc..47bfeee 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 83568fd..029b9fa 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 bcd2c3c..9da7747 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 c4819e3..0d69946 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 e65fffc..da645a4 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 cc3455a..4fc349e 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) {