Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions src/web/backend/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)

Expand Down
29 changes: 28 additions & 1 deletion src/web/backend/auth/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions src/web/backend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 5 additions & 2 deletions src/web/frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -83,6 +85,7 @@ export default function App() {

return (
<Settings
authDisabled={authDisabled}
onWizard={() => setView('wizard')}
onLogout={() => logout().then(() => setView('login'))}
/>
Expand Down
23 changes: 16 additions & 7 deletions src/web/frontend/src/components/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -1012,12 +1012,21 @@ export default function Settings({ onWizard, onLogout }) {
<button className={tabBtnCls(activeTab === 'config')} onClick={() => setActiveTab('config')}>Settings</button>
<button className={tabBtnCls(activeTab === 'logs')} onClick={() => setActiveTab('logs')}>Logs</button>
</nav>
<button
onClick={onLogout}
className="pb-2 text-[12px] text-muted hover:text-white transition-colors cursor-pointer bg-transparent border-none"
>
Sign out
</button>
{authDisabled ? (
<span
title="UI_USERNAME and UI_PASSWORD are not set, so the web UI is open to anyone who can reach it."
className="pb-2 text-[12px] text-muted/70 cursor-default select-none"
>
Auth disabled
</span>
) : (
<button
onClick={onLogout}
className="pb-2 text-[12px] text-muted hover:text-white transition-colors cursor-pointer bg-transparent border-none"
>
Sign out
</button>
)}
</header>

{activeTab === 'run' && <HomeSection />}
Expand Down
4 changes: 3 additions & 1 deletion src/web/frontend/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading