Skip to content

Commit 5ece92f

Browse files
committed
fix(ui): Move routes to /app to avoid conflict with API endpoints
Also test for regressions in HTTP GET API key exempted endpoints because this list can get out of sync with the UI routes. Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 0b53bc5 commit 5ece92f

28 files changed

Lines changed: 407 additions & 139 deletions

core/cli/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ type RunCMD struct {
6868
UseSubtleKeyComparison bool `env:"LOCALAI_SUBTLE_KEY_COMPARISON" default:"false" help:"If true, API Key validation comparisons will be performed using constant-time comparisons rather than simple equality. This trades off performance on each request for resiliancy against timing attacks." group:"hardening"`
6969
DisableApiKeyRequirementForHttpGet bool `env:"LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET" default:"false" help:"If true, a valid API key is not required to issue GET requests to portions of the web ui. This should only be enabled in secure testing environments" group:"hardening"`
7070
DisableMetricsEndpoint bool `env:"LOCALAI_DISABLE_METRICS_ENDPOINT,DISABLE_METRICS_ENDPOINT" default:"false" help:"Disable the /metrics endpoint" group:"api"`
71-
HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/browse/?$,^/talk/?$,^/p2p/?$,^/chat/?$,^/image/?$,^/text2image/?$,^/tts/?$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"`
71+
HttpGetExemptedEndpoints []string `env:"LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS" default:"^/$,^/app(/.*)?$,^/browse(/.*)?$,^/login/?$,^/explorer/?$,^/assets/.*$,^/static/.*$,^/swagger.*$" help:"If LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET is overriden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review" group:"hardening"`
7272
Peer2Peer bool `env:"LOCALAI_P2P,P2P" name:"p2p" default:"false" help:"Enable P2P mode" group:"p2p"`
7373
Peer2PeerDHTInterval int `env:"LOCALAI_P2P_DHT_INTERVAL,P2P_DHT_INTERVAL" default:"360" name:"p2p-dht-interval" help:"Interval for DHT refresh (used during token generation)" group:"p2p"`
7474
Peer2PeerOTPInterval int `env:"LOCALAI_P2P_OTP_INTERVAL,P2P_OTP_INTERVAL" default:"9000" name:"p2p-otp-interval" help:"Interval for OTP refresh (used during token generation)" group:"p2p"`

core/http/app.go

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,31 @@ func API(application *application.Application) (*echo.Echo, error) {
270270
// Enable SPA fallback in the 404 handler for client-side routing
271271
spaFallback = serveIndex
272272

273-
// Serve React SPA at /
274-
e.GET("/", serveIndex)
273+
// Serve React SPA at /app
274+
e.GET("/app", serveIndex)
275+
e.GET("/app/*", serveIndex)
276+
277+
// prefixRedirect performs a redirect that preserves X-Forwarded-Prefix for reverse-proxy support.
278+
prefixRedirect := func(c echo.Context, target string) error {
279+
if prefix := c.Request().Header.Get("X-Forwarded-Prefix"); prefix != "" {
280+
target = strings.TrimSuffix(prefix, "/") + target
281+
}
282+
return c.Redirect(http.StatusMovedPermanently, target)
283+
}
284+
285+
// Redirect / to /app
286+
e.GET("/", func(c echo.Context) error {
287+
return prefixRedirect(c, "/app")
288+
})
289+
290+
// Backward compatibility: redirect /browse/* to /app/*
291+
e.GET("/browse", func(c echo.Context) error {
292+
return prefixRedirect(c, "/app")
293+
})
294+
e.GET("/browse/*", func(c echo.Context) error {
295+
p := c.Param("*")
296+
return prefixRedirect(c, "/app/"+p)
297+
})
275298

276299
// Serve React static assets (JS, CSS, etc.)
277300
serveReactAsset := func(c echo.Context) error {
@@ -291,15 +314,6 @@ func API(application *application.Application) (*echo.Echo, error) {
291314
return echo.NewHTTPError(http.StatusNotFound)
292315
}
293316
e.GET("/assets/*", serveReactAsset)
294-
295-
// Backward compatibility: redirect /app/* to /*
296-
e.GET("/app", func(c echo.Context) error {
297-
return c.Redirect(http.StatusMovedPermanently, "/")
298-
})
299-
e.GET("/app/*", func(c echo.Context) error {
300-
p := c.Param("*")
301-
return c.Redirect(http.StatusMovedPermanently, "/"+p)
302-
})
303317
}
304318
}
305319
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())

core/http/middleware/auth_test.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package middleware_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
7+
"github.com/labstack/echo/v4"
8+
"github.com/mudler/LocalAI/core/config"
9+
. "github.com/mudler/LocalAI/core/http/middleware"
10+
. "github.com/onsi/ginkgo/v2"
11+
. "github.com/onsi/gomega"
12+
)
13+
14+
// ok is a simple handler that returns 200 OK.
15+
func ok(c echo.Context) error {
16+
return c.String(http.StatusOK, "ok")
17+
}
18+
19+
// newAuthApp creates a minimal Echo app with auth middleware applied.
20+
// Requests that fail auth with Content-Type: application/json get a JSON 401
21+
// (no template renderer needed).
22+
func newAuthApp(appConfig *config.ApplicationConfig) *echo.Echo {
23+
e := echo.New()
24+
25+
mw, err := GetKeyAuthConfig(appConfig)
26+
Expect(err).ToNot(HaveOccurred())
27+
e.Use(mw)
28+
29+
// Sensitive API routes
30+
e.GET("/v1/models", ok)
31+
e.POST("/v1/chat/completions", ok)
32+
33+
// UI routes
34+
e.GET("/app", ok)
35+
e.GET("/app/*", ok)
36+
e.GET("/browse", ok)
37+
e.GET("/browse/*", ok)
38+
e.GET("/login", ok)
39+
e.GET("/explorer", ok)
40+
e.GET("/assets/*", ok)
41+
e.POST("/app", ok)
42+
43+
return e
44+
}
45+
46+
// doRequest performs an HTTP request against the given Echo app and returns the recorder.
47+
func doRequest(e *echo.Echo, method, path string, opts ...func(*http.Request)) *httptest.ResponseRecorder {
48+
req := httptest.NewRequest(method, path, nil)
49+
req.Header.Set("Content-Type", "application/json")
50+
for _, opt := range opts {
51+
opt(req)
52+
}
53+
rec := httptest.NewRecorder()
54+
e.ServeHTTP(rec, req)
55+
return rec
56+
}
57+
58+
func withBearerToken(token string) func(*http.Request) {
59+
return func(req *http.Request) {
60+
req.Header.Set("Authorization", "Bearer "+token)
61+
}
62+
}
63+
64+
func withXApiKey(key string) func(*http.Request) {
65+
return func(req *http.Request) {
66+
req.Header.Set("x-api-key", key)
67+
}
68+
}
69+
70+
func withXiApiKey(key string) func(*http.Request) {
71+
return func(req *http.Request) {
72+
req.Header.Set("xi-api-key", key)
73+
}
74+
}
75+
76+
func withTokenCookie(token string) func(*http.Request) {
77+
return func(req *http.Request) {
78+
req.AddCookie(&http.Cookie{Name: "token", Value: token})
79+
}
80+
}
81+
82+
var _ = Describe("Auth Middleware", func() {
83+
84+
Context("when API keys are configured", func() {
85+
var app *echo.Echo
86+
const validKey = "sk-test-key-123"
87+
88+
BeforeEach(func() {
89+
appConfig := config.NewApplicationConfig()
90+
appConfig.ApiKeys = []string{validKey}
91+
app = newAuthApp(appConfig)
92+
})
93+
94+
It("returns 401 for GET request without a key", func() {
95+
rec := doRequest(app, http.MethodGet, "/v1/models")
96+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
97+
})
98+
99+
It("returns 401 for POST request without a key", func() {
100+
rec := doRequest(app, http.MethodPost, "/v1/chat/completions")
101+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
102+
})
103+
104+
It("returns 401 for request with an invalid key", func() {
105+
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("wrong-key"))
106+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
107+
})
108+
109+
It("passes through with valid Bearer token in Authorization header", func() {
110+
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(validKey))
111+
Expect(rec.Code).To(Equal(http.StatusOK))
112+
})
113+
114+
It("passes through with valid x-api-key header", func() {
115+
rec := doRequest(app, http.MethodGet, "/v1/models", withXApiKey(validKey))
116+
Expect(rec.Code).To(Equal(http.StatusOK))
117+
})
118+
119+
It("passes through with valid xi-api-key header", func() {
120+
rec := doRequest(app, http.MethodGet, "/v1/models", withXiApiKey(validKey))
121+
Expect(rec.Code).To(Equal(http.StatusOK))
122+
})
123+
124+
It("passes through with valid token cookie", func() {
125+
rec := doRequest(app, http.MethodGet, "/v1/models", withTokenCookie(validKey))
126+
Expect(rec.Code).To(Equal(http.StatusOK))
127+
})
128+
})
129+
130+
Context("when no API keys are configured", func() {
131+
var app *echo.Echo
132+
133+
BeforeEach(func() {
134+
appConfig := config.NewApplicationConfig()
135+
app = newAuthApp(appConfig)
136+
})
137+
138+
It("passes through without any key", func() {
139+
rec := doRequest(app, http.MethodGet, "/v1/models")
140+
Expect(rec.Code).To(Equal(http.StatusOK))
141+
})
142+
})
143+
144+
Context("GET exempted endpoints (feature enabled)", func() {
145+
var app *echo.Echo
146+
const validKey = "sk-test-key-456"
147+
148+
BeforeEach(func() {
149+
appConfig := config.NewApplicationConfig(
150+
config.WithApiKeys([]string{validKey}),
151+
config.WithDisableApiKeyRequirementForHttpGet(true),
152+
config.WithHttpGetExemptedEndpoints([]string{
153+
"^/$",
154+
"^/app(/.*)?$",
155+
"^/browse(/.*)?$",
156+
"^/login/?$",
157+
"^/explorer/?$",
158+
"^/assets/.*$",
159+
"^/static/.*$",
160+
"^/swagger.*$",
161+
}),
162+
)
163+
app = newAuthApp(appConfig)
164+
})
165+
166+
It("allows GET to /app without a key", func() {
167+
rec := doRequest(app, http.MethodGet, "/app")
168+
Expect(rec.Code).To(Equal(http.StatusOK))
169+
})
170+
171+
It("allows GET to /app/chat/model sub-route without a key", func() {
172+
rec := doRequest(app, http.MethodGet, "/app/chat/llama3")
173+
Expect(rec.Code).To(Equal(http.StatusOK))
174+
})
175+
176+
It("allows GET to /browse/models without a key", func() {
177+
rec := doRequest(app, http.MethodGet, "/browse/models")
178+
Expect(rec.Code).To(Equal(http.StatusOK))
179+
})
180+
181+
It("allows GET to /login without a key", func() {
182+
rec := doRequest(app, http.MethodGet, "/login")
183+
Expect(rec.Code).To(Equal(http.StatusOK))
184+
})
185+
186+
It("allows GET to /explorer without a key", func() {
187+
rec := doRequest(app, http.MethodGet, "/explorer")
188+
Expect(rec.Code).To(Equal(http.StatusOK))
189+
})
190+
191+
It("allows GET to /assets/main.js without a key", func() {
192+
rec := doRequest(app, http.MethodGet, "/assets/main.js")
193+
Expect(rec.Code).To(Equal(http.StatusOK))
194+
})
195+
196+
It("rejects POST to /app without a key", func() {
197+
rec := doRequest(app, http.MethodPost, "/app")
198+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
199+
})
200+
201+
It("rejects GET to /v1/models without a key", func() {
202+
rec := doRequest(app, http.MethodGet, "/v1/models")
203+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
204+
})
205+
})
206+
207+
Context("GET exempted endpoints (feature disabled)", func() {
208+
var app *echo.Echo
209+
const validKey = "sk-test-key-789"
210+
211+
BeforeEach(func() {
212+
appConfig := config.NewApplicationConfig(
213+
config.WithApiKeys([]string{validKey}),
214+
// DisableApiKeyRequirementForHttpGet defaults to false
215+
config.WithHttpGetExemptedEndpoints([]string{
216+
"^/$",
217+
"^/app(/.*)?$",
218+
}),
219+
)
220+
app = newAuthApp(appConfig)
221+
})
222+
223+
It("requires auth for GET to /app even though it matches exempted pattern", func() {
224+
rec := doRequest(app, http.MethodGet, "/app")
225+
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
226+
})
227+
})
228+
})

core/http/react-ui/src/App.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default function App() {
1515
const { toasts, addToast, removeToast } = useToast()
1616
const [version, setVersion] = useState('')
1717
const location = useLocation()
18-
const isChatRoute = location.pathname.startsWith('/chat') || location.pathname.match(/^\/agents\/[^/]+\/chat/)
18+
const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/chat/)
1919

2020
useEffect(() => {
2121
systemApi.version()

core/http/react-ui/src/components/Sidebar.jsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,36 @@ import ThemeToggle from './ThemeToggle'
55
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
66

77
const mainItems = [
8-
{ path: '/', icon: 'fas fa-home', label: 'Home' },
9-
{ path: '/browse', icon: 'fas fa-download', label: 'Install Models' },
10-
{ path: '/chat', icon: 'fas fa-comments', label: 'Chat' },
11-
{ path: '/image', icon: 'fas fa-image', label: 'Images' },
12-
{ path: '/video', icon: 'fas fa-video', label: 'Video' },
13-
{ path: '/tts', icon: 'fas fa-music', label: 'TTS' },
14-
{ path: '/sound', icon: 'fas fa-volume-high', label: 'Sound' },
15-
{ path: '/talk', icon: 'fas fa-phone', label: 'Talk' },
8+
{ path: '/app', icon: 'fas fa-home', label: 'Home' },
9+
{ path: '/app/models', icon: 'fas fa-download', label: 'Install Models' },
10+
{ path: '/app/chat', icon: 'fas fa-comments', label: 'Chat' },
11+
{ path: '/app/image', icon: 'fas fa-image', label: 'Images' },
12+
{ path: '/app/video', icon: 'fas fa-video', label: 'Video' },
13+
{ path: '/app/tts', icon: 'fas fa-music', label: 'TTS' },
14+
{ path: '/app/sound', icon: 'fas fa-volume-high', label: 'Sound' },
15+
{ path: '/app/talk', icon: 'fas fa-phone', label: 'Talk' },
1616
]
1717

1818
const agentItems = [
19-
{ path: '/agents', icon: 'fas fa-robot', label: 'Agents' },
20-
{ path: '/skills', icon: 'fas fa-wand-magic-sparkles', label: 'Skills' },
21-
{ path: '/collections', icon: 'fas fa-database', label: 'Memory' },
22-
{ path: '/agent-jobs', icon: 'fas fa-tasks', label: 'MCP CI Jobs', feature: 'mcp' },
19+
{ path: '/app/agents', icon: 'fas fa-robot', label: 'Agents' },
20+
{ path: '/app/skills', icon: 'fas fa-wand-magic-sparkles', label: 'Skills' },
21+
{ path: '/app/collections', icon: 'fas fa-database', label: 'Memory' },
22+
{ path: '/app/agent-jobs', icon: 'fas fa-tasks', label: 'MCP CI Jobs', feature: 'mcp' },
2323
]
2424

2525
const systemItems = [
26-
{ path: '/backends', icon: 'fas fa-server', label: 'Backends' },
27-
{ path: '/traces', icon: 'fas fa-chart-line', label: 'Traces' },
28-
{ path: '/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm' },
29-
{ path: '/manage', icon: 'fas fa-desktop', label: 'System' },
30-
{ path: '/settings', icon: 'fas fa-cog', label: 'Settings' },
26+
{ path: '/app/backends', icon: 'fas fa-server', label: 'Backends' },
27+
{ path: '/app/traces', icon: 'fas fa-chart-line', label: 'Traces' },
28+
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm' },
29+
{ path: '/app/manage', icon: 'fas fa-desktop', label: 'System' },
30+
{ path: '/app/settings', icon: 'fas fa-cog', label: 'Settings' },
3131
]
3232

3333
function NavItem({ item, onClose, collapsed }) {
3434
return (
3535
<NavLink
3636
to={item.path}
37-
end={item.path === '/'}
37+
end={item.path === '/app'}
3838
className={({ isActive }) =>
3939
`nav-item ${isActive ? 'active' : ''}`
4040
}

core/http/react-ui/src/hooks/useOperations.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export function useOperations(pollInterval = 1000) {
88
const intervalRef = useRef(null)
99

1010
const previousCountRef = useRef(0)
11+
const onAllCompleteRef = useRef(null)
1112

1213
const fetchOperations = useCallback(async () => {
1314
try {
@@ -19,10 +20,9 @@ export function useOperations(pollInterval = 1000) {
1920
const activeOps = ops.filter(op => !op.error)
2021
const failedOps = ops.filter(op => op.error)
2122

22-
// Auto-refresh the page when all active operations complete (mirrors original behavior)
23-
// but not when there are still failed operations being shown
23+
// Notify when all operations complete (no active or failed remaining)
2424
if (previousCountRef.current > 0 && activeOps.length === 0 && failedOps.length === 0) {
25-
setTimeout(() => window.location.reload(), 1000)
25+
onAllCompleteRef.current?.()
2626
}
2727
previousCountRef.current = activeOps.length
2828

@@ -64,5 +64,10 @@ export function useOperations(pollInterval = 1000) {
6464
}
6565
}, [fetchOperations, pollInterval])
6666

67-
return { operations, loading, error, cancelOperation, dismissFailedOp, refetch: fetchOperations }
67+
// Allow callers to register a callback for when all operations finish
68+
const onAllComplete = useCallback((cb) => {
69+
onAllCompleteRef.current = cb
70+
}, [])
71+
72+
return { operations, loading, error, cancelOperation, dismissFailedOp, refetch: fetchOperations, onAllComplete }
6873
}

core/http/react-ui/src/pages/AgentChat.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ export default function AgentChat() {
456456
<i className="fas fa-layer-group" /> {artifacts.length}
457457
</button>
458458
)}
459-
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/status`)} title="View status & observables">
459+
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/status`)} title="View status & observables">
460460
<i className="fas fa-chart-bar" /> Status
461461
</button>
462462
<button className="btn btn-secondary btn-sm" onClick={() => clearMessages()} disabled={messages.length === 0} title="Clear chat history">

0 commit comments

Comments
 (0)