diff --git a/Dockerfile b/Dockerfile index 84e8750..7eff4a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,8 +18,8 @@ RUN npm install -g pnpm ARG REQUEST_TIMEOUT=60000 ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT -ARG VITE_BASE_PATH="" -ENV VITE_BASE_PATH=$VITE_BASE_PATH +ARG BASE_PATH="" +ENV BASE_PATH=$BASE_PATH ENV PNPM_HOME=/usr/local/share/pnpm ENV PATH=$PNPM_HOME:$PATH diff --git a/frontend/index.html b/frontend/index.html index e1203c4..8449b96 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,16 +1,13 @@ - MCP Hub Dashboard - + -
- + - \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2687561..84aff6a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,13 +15,16 @@ import LogsPage from './pages/LogsPage'; // Get base path from environment variable or default to empty string const getBasePath = (): string => { - const basePath = import.meta.env.VITE_BASE_PATH || ''; - return basePath.startsWith('/') ? basePath : ''; + const basePath = import.meta.env.BASE_PATH || ''; + // Ensure the path starts with / if it's not empty and doesn't already start with / + if (basePath && !basePath.startsWith('/')) { + return '/' + basePath; + } + return basePath; }; function App() { const basename = getBasePath(); - return ( diff --git a/frontend/src/components/AddServerForm.tsx b/frontend/src/components/AddServerForm.tsx index 4d84a79..4d2030d 100644 --- a/frontend/src/components/AddServerForm.tsx +++ b/frontend/src/components/AddServerForm.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import ServerForm from './ServerForm' +import { getApiUrl } from '../utils/api' interface AddServerFormProps { onAdd: () => void @@ -20,7 +21,7 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => { try { setError(null) const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/servers', { + const response = await fetch(getApiUrl('/servers'), { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/hooks/useGroupData.ts b/frontend/src/hooks/useGroupData.ts index 4f28286..3d48c69 100644 --- a/frontend/src/hooks/useGroupData.ts +++ b/frontend/src/hooks/useGroupData.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Group, ApiResponse } from '@/types'; +import { getApiUrl } from '../utils/api'; export const useGroupData = () => { const { t } = useTranslation(); @@ -13,25 +14,25 @@ export const useGroupData = () => { try { setLoading(true); const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/groups', { + const response = await fetch(getApiUrl('/groups'), { headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); - + if (!response.ok) { throw new Error(`Status: ${response.status}`); } - + const data: ApiResponse = await response.json(); - + if (data && data.success && Array.isArray(data.data)) { setGroups(data.data); } else { console.error('Invalid group data format:', data); setGroups([]); } - + setError(null); } catch (err) { console.error('Error fetching groups:', err); @@ -44,29 +45,29 @@ export const useGroupData = () => { // Trigger a refresh of the groups data const triggerRefresh = useCallback(() => { - setRefreshKey(prev => prev + 1); + setRefreshKey((prev) => prev + 1); }, []); // Create a new group with server associations const createGroup = async (name: string, description?: string, servers: string[] = []) => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/groups', { + const response = await fetch(getApiUrl('/groups'), { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-auth-token': token || '' + 'x-auth-token': token || '', }, body: JSON.stringify({ name, description, servers }), }); - + const result: ApiResponse = await response.json(); - + if (!response.ok) { setError(result.message || t('groups.createError')); return null; } - + triggerRefresh(); return result.data || null; } catch (err) { @@ -76,25 +77,28 @@ export const useGroupData = () => { }; // Update an existing group with server associations - const updateGroup = async (id: string, data: { name?: string; description?: string; servers?: string[] }) => { + const updateGroup = async ( + id: string, + data: { name?: string; description?: string; servers?: string[] }, + ) => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/groups/${id}`, { + const response = await fetch(getApiUrl(`/groups/${id}`), { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'x-auth-token': token || '' + 'x-auth-token': token || '', }, body: JSON.stringify(data), }); - + const result: ApiResponse = await response.json(); - + if (!response.ok) { setError(result.message || t('groups.updateError')); return null; } - + triggerRefresh(); return result.data || null; } catch (err) { @@ -107,22 +111,22 @@ export const useGroupData = () => { const updateGroupServers = async (groupId: string, servers: string[]) => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/groups/${groupId}/servers/batch`, { + const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), { method: 'PUT', headers: { 'Content-Type': 'application/json', - 'x-auth-token': token || '' + 'x-auth-token': token || '', }, body: JSON.stringify({ servers }), }); - + const result: ApiResponse = await response.json(); - + if (!response.ok) { setError(result.message || t('groups.updateError')); return null; } - + triggerRefresh(); return result.data || null; } catch (err) { @@ -135,20 +139,20 @@ export const useGroupData = () => { const deleteGroup = async (id: string) => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/groups/${id}`, { + const response = await fetch(getApiUrl(`/groups/${id}`), { method: 'DELETE', headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); - + const result = await response.json(); - + if (!response.ok) { setError(result.message || t('groups.deleteError')); return false; } - + triggerRefresh(); return true; } catch (err) { @@ -161,22 +165,22 @@ export const useGroupData = () => { const addServerToGroup = async (groupId: string, serverName: string) => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/groups/${groupId}/servers`, { + const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-auth-token': token || '' + 'x-auth-token': token || '', }, body: JSON.stringify({ serverName }), }); - + const result: ApiResponse = await response.json(); - + if (!response.ok) { setError(result.message || t('groups.serverAddError')); return null; } - + triggerRefresh(); return result.data || null; } catch (err) { @@ -189,20 +193,20 @@ export const useGroupData = () => { const removeServerFromGroup = async (groupId: string, serverName: string) => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/groups/${groupId}/servers/${serverName}`, { + const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), { method: 'DELETE', headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); - + const result: ApiResponse = await response.json(); - + if (!response.ok) { setError(result.message || t('groups.serverRemoveError')); return null; } - + triggerRefresh(); return result.data || null; } catch (err) { @@ -227,6 +231,6 @@ export const useGroupData = () => { updateGroupServers, deleteGroup, addServerToGroup, - removeServerFromGroup + removeServerFromGroup, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useMarketData.ts b/frontend/src/hooks/useMarketData.ts index 32e175a..6b80506 100644 --- a/frontend/src/hooks/useMarketData.ts +++ b/frontend/src/hooks/useMarketData.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { MarketServer, ApiResponse } from '@/types'; +import { getApiUrl } from '../utils/api'; export const useMarketData = () => { const { t } = useTranslation(); @@ -15,7 +16,7 @@ export const useMarketData = () => { const [error, setError] = useState(null); const [currentServer, setCurrentServer] = useState(null); const [installedServers, setInstalledServers] = useState([]); - + // Pagination states const [currentPage, setCurrentPage] = useState(1); const [serversPerPage, setServersPerPage] = useState(9); @@ -26,18 +27,18 @@ export const useMarketData = () => { try { setLoading(true); const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/market/servers', { + const response = await fetch(getApiUrl('/market/servers'), { headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); - + if (!response.ok) { throw new Error(`Status: ${response.status}`); } - + const data: ApiResponse = await response.json(); - + if (data && data.success && Array.isArray(data.data)) { setAllServers(data.data); // Apply pagination to the fetched data @@ -55,44 +56,50 @@ export const useMarketData = () => { }, [t]); // Apply pagination to data - const applyPagination = useCallback((data: MarketServer[], page: number, itemsPerPage = serversPerPage) => { - const totalItems = data.length; - const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage); - setTotalPages(calculatedTotalPages); + const applyPagination = useCallback( + (data: MarketServer[], page: number, itemsPerPage = serversPerPage) => { + const totalItems = data.length; + const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage); + setTotalPages(calculatedTotalPages); - // Ensure current page is valid - const validPage = Math.max(1, Math.min(page, calculatedTotalPages)); - if (validPage !== page) { - setCurrentPage(validPage); - } + // Ensure current page is valid + const validPage = Math.max(1, Math.min(page, calculatedTotalPages)); + if (validPage !== page) { + setCurrentPage(validPage); + } - const startIndex = (validPage - 1) * itemsPerPage; - const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage); - setServers(paginatedServers); - }, [serversPerPage]); + const startIndex = (validPage - 1) * itemsPerPage; + const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage); + setServers(paginatedServers); + }, + [serversPerPage], + ); // Change page - const changePage = useCallback((page: number) => { - setCurrentPage(page); - applyPagination(allServers, page, serversPerPage); - }, [allServers, applyPagination, serversPerPage]); + const changePage = useCallback( + (page: number) => { + setCurrentPage(page); + applyPagination(allServers, page, serversPerPage); + }, + [allServers, applyPagination, serversPerPage], + ); // Fetch all categories const fetchCategories = useCallback(async () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/market/categories', { + const response = await fetch(getApiUrl('/market/categories'), { headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); - + if (!response.ok) { throw new Error(`Status: ${response.status}`); } - + const data: ApiResponse = await response.json(); - + if (data && data.success && Array.isArray(data.data)) { setCategories(data.data); } else { @@ -107,18 +114,18 @@ export const useMarketData = () => { const fetchTags = useCallback(async () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/market/tags', { + const response = await fetch(getApiUrl('/market/tags'), { headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); - + if (!response.ok) { throw new Error(`Status: ${response.status}`); } - + const data: ApiResponse = await response.json(); - + if (data && data.success && Array.isArray(data.data)) { setTags(data.data); } else { @@ -130,178 +137,196 @@ export const useMarketData = () => { }, []); // Fetch server by name - const fetchServerByName = useCallback(async (name: string) => { - try { - setLoading(true); - const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/market/servers/${name}`, { - headers: { - 'x-auth-token': token || '' + const fetchServerByName = useCallback( + async (name: string) => { + try { + setLoading(true); + const token = localStorage.getItem('mcphub_token'); + const response = await fetch(getApiUrl(`/market/servers/${name}`), { + headers: { + 'x-auth-token': token || '', + }, + }); + + if (!response.ok) { + throw new Error(`Status: ${response.status}`); } - }); - - if (!response.ok) { - throw new Error(`Status: ${response.status}`); - } - - const data: ApiResponse = await response.json(); - - if (data && data.success && data.data) { - setCurrentServer(data.data); - return data.data; - } else { - console.error('Invalid server data format:', data); - setError(t('market.serverNotFound')); + + const data: ApiResponse = await response.json(); + + if (data && data.success && data.data) { + setCurrentServer(data.data); + return data.data; + } else { + console.error('Invalid server data format:', data); + setError(t('market.serverNotFound')); + return null; + } + } catch (err) { + console.error(`Error fetching server ${name}:`, err); + setError(err instanceof Error ? err.message : String(err)); return null; + } finally { + setLoading(false); } - } catch (err) { - console.error(`Error fetching server ${name}:`, err); - setError(err instanceof Error ? err.message : String(err)); - return null; - } finally { - setLoading(false); - } - }, [t]); + }, + [t], + ); // Search servers by query - const searchServers = useCallback(async (query: string) => { - try { - setLoading(true); - setSearchQuery(query); - - if (!query.trim()) { - // Fetch fresh data from server instead of just applying pagination - fetchMarketServers(); - return; - } - - const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/market/servers/search?query=${encodeURIComponent(query)}`, { - headers: { - 'x-auth-token': token || '' + const searchServers = useCallback( + async (query: string) => { + try { + setLoading(true); + setSearchQuery(query); + + if (!query.trim()) { + // Fetch fresh data from server instead of just applying pagination + fetchMarketServers(); + return; } - }); - - if (!response.ok) { - throw new Error(`Status: ${response.status}`); + + const token = localStorage.getItem('mcphub_token'); + const response = await fetch( + getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`), + { + headers: { + 'x-auth-token': token || '', + }, + }, + ); + + if (!response.ok) { + throw new Error(`Status: ${response.status}`); + } + + const data: ApiResponse = await response.json(); + + if (data && data.success && Array.isArray(data.data)) { + setAllServers(data.data); + setCurrentPage(1); + applyPagination(data.data, 1); + } else { + console.error('Invalid search results format:', data); + setError(t('market.searchError')); + } + } catch (err) { + console.error('Error searching servers:', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); } - - const data: ApiResponse = await response.json(); - - if (data && data.success && Array.isArray(data.data)) { - setAllServers(data.data); - setCurrentPage(1); - applyPagination(data.data, 1); - } else { - console.error('Invalid search results format:', data); - setError(t('market.searchError')); - } - } catch (err) { - console.error('Error searching servers:', err); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, [t, allServers, applyPagination, fetchMarketServers]); + }, + [t, allServers, applyPagination, fetchMarketServers], + ); // Filter servers by category - const filterByCategory = useCallback(async (category: string) => { - try { - setLoading(true); - setSelectedCategory(category); - setSelectedTag(''); // Reset tag filter when filtering by category - - if (!category) { - fetchMarketServers(); - return; - } - - const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/market/categories/${encodeURIComponent(category)}`, { - headers: { - 'x-auth-token': token || '' + const filterByCategory = useCallback( + async (category: string) => { + try { + setLoading(true); + setSelectedCategory(category); + setSelectedTag(''); // Reset tag filter when filtering by category + + if (!category) { + fetchMarketServers(); + return; } - }); - - if (!response.ok) { - throw new Error(`Status: ${response.status}`); + + const token = localStorage.getItem('mcphub_token'); + const response = await fetch( + getApiUrl(`/market/categories/${encodeURIComponent(category)}`), + { + headers: { + 'x-auth-token': token || '', + }, + }, + ); + + if (!response.ok) { + throw new Error(`Status: ${response.status}`); + } + + const data: ApiResponse = await response.json(); + + if (data && data.success && Array.isArray(data.data)) { + setAllServers(data.data); + setCurrentPage(1); + applyPagination(data.data, 1); + } else { + console.error('Invalid category filter results format:', data); + setError(t('market.filterError')); + } + } catch (err) { + console.error('Error filtering servers by category:', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); } - - const data: ApiResponse = await response.json(); - - if (data && data.success && Array.isArray(data.data)) { - setAllServers(data.data); - setCurrentPage(1); - applyPagination(data.data, 1); - } else { - console.error('Invalid category filter results format:', data); - setError(t('market.filterError')); - } - } catch (err) { - console.error('Error filtering servers by category:', err); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, [t, fetchMarketServers, applyPagination]); + }, + [t, fetchMarketServers, applyPagination], + ); // Filter servers by tag - const filterByTag = useCallback(async (tag: string) => { - try { - setLoading(true); - setSelectedTag(tag); - setSelectedCategory(''); // Reset category filter when filtering by tag - - if (!tag) { - fetchMarketServers(); - return; - } - - const token = localStorage.getItem('mcphub_token'); - const response = await fetch(`/api/market/tags/${encodeURIComponent(tag)}`, { - headers: { - 'x-auth-token': token || '' + const filterByTag = useCallback( + async (tag: string) => { + try { + setLoading(true); + setSelectedTag(tag); + setSelectedCategory(''); // Reset category filter when filtering by tag + + if (!tag) { + fetchMarketServers(); + return; } - }); - - if (!response.ok) { - throw new Error(`Status: ${response.status}`); + + const token = localStorage.getItem('mcphub_token'); + const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), { + headers: { + 'x-auth-token': token || '', + }, + }); + + if (!response.ok) { + throw new Error(`Status: ${response.status}`); + } + + const data: ApiResponse = await response.json(); + + if (data && data.success && Array.isArray(data.data)) { + setAllServers(data.data); + setCurrentPage(1); + applyPagination(data.data, 1); + } else { + console.error('Invalid tag filter results format:', data); + setError(t('market.tagFilterError')); + } + } catch (err) { + console.error('Error filtering servers by tag:', err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); } - - const data: ApiResponse = await response.json(); - - if (data && data.success && Array.isArray(data.data)) { - setAllServers(data.data); - setCurrentPage(1); - applyPagination(data.data, 1); - } else { - console.error('Invalid tag filter results format:', data); - setError(t('market.tagFilterError')); - } - } catch (err) { - console.error('Error filtering servers by tag:', err); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, [t, fetchMarketServers, applyPagination]); + }, + [t, fetchMarketServers, applyPagination], + ); // Fetch installed servers const fetchInstalledServers = useCallback(async () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/servers', { + const response = await fetch(getApiUrl('/servers'), { headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); - + if (!response.ok) { throw new Error(`Status: ${response.status}`); } - + const data = await response.json(); - + if (data && data.success && Array.isArray(data.data)) { // Extract server names const installedServerNames = data.data.map((server: any) => server.name); @@ -313,64 +338,77 @@ export const useMarketData = () => { }, []); // Check if a server is already installed - const isServerInstalled = useCallback((serverName: string) => { - return installedServers.includes(serverName); - }, [installedServers]); + const isServerInstalled = useCallback( + (serverName: string) => { + return installedServers.includes(serverName); + }, + [installedServers], + ); // Install server to the local environment - const installServer = useCallback(async (server: MarketServer) => { - try { - const installType = server.installations?.npm ? 'npm' : Object.keys(server.installations || {}).length > 0 ? Object.keys(server.installations)[0] : null; - - if (!installType || !server.installations?.[installType]) { - setError(t('market.noInstallationMethod')); + const installServer = useCallback( + async (server: MarketServer) => { + try { + const installType = server.installations?.npm + ? 'npm' + : Object.keys(server.installations || {}).length > 0 + ? Object.keys(server.installations)[0] + : null; + + if (!installType || !server.installations?.[installType]) { + setError(t('market.noInstallationMethod')); + return false; + } + + const installation = server.installations[installType]; + + // Prepare server configuration + const serverConfig = { + name: server.name, + config: { + command: installation.command, + args: installation.args, + env: installation.env || {}, + }, + }; + + // Call the createServer API + const token = localStorage.getItem('mcphub_token'); + const response = await fetch(getApiUrl('/servers'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-auth-token': token || '', + }, + body: JSON.stringify(serverConfig), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || `Status: ${response.status}`); + } + + // Update installed servers list after successful installation + await fetchInstalledServers(); + return true; + } catch (err) { + console.error('Error installing server:', err); + setError(err instanceof Error ? err.message : String(err)); return false; } - - const installation = server.installations[installType]; - - // Prepare server configuration - const serverConfig = { - name: server.name, - config: { - command: installation.command, - args: installation.args, - env: installation.env || {} - } - }; - - // Call the createServer API - const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/servers', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-auth-token': token || '' - }, - body: JSON.stringify(serverConfig), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || `Status: ${response.status}`); - } - - // Update installed servers list after successful installation - await fetchInstalledServers(); - return true; - } catch (err) { - console.error('Error installing server:', err); - setError(err instanceof Error ? err.message : String(err)); - return false; - } - }, [t, fetchInstalledServers]); + }, + [t, fetchInstalledServers], + ); // Change servers per page - const changeServersPerPage = useCallback((perPage: number) => { - setServersPerPage(perPage); - setCurrentPage(1); - applyPagination(allServers, 1, perPage); - }, [allServers, applyPagination]); + const changeServersPerPage = useCallback( + (perPage: number) => { + setServersPerPage(perPage); + setCurrentPage(1); + applyPagination(allServers, 1, perPage); + }, + [allServers, applyPagination], + ); // Load initial data useEffect(() => { @@ -405,6 +443,6 @@ export const useMarketData = () => { changePage, changeServersPerPage, // Installed servers methods - isServerInstalled + isServerInstalled, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useServerData.ts b/frontend/src/hooks/useServerData.ts index b88e402..69a24c3 100644 --- a/frontend/src/hooks/useServerData.ts +++ b/frontend/src/hooks/useServerData.ts @@ -1,18 +1,19 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Server, ApiResponse } from '@/types'; +import { getApiUrl } from '../utils/api'; // Configuration options const CONFIG = { // Initialization phase configuration startup: { - maxAttempts: 60, // Maximum number of attempts during initialization - pollingInterval: 3000 // Polling interval during initialization (3 seconds) + maxAttempts: 60, // Maximum number of attempts during initialization + pollingInterval: 3000, // Polling interval during initialization (3 seconds) }, // Normal operation phase configuration normal: { - pollingInterval: 10000 // Polling interval during normal operation (10 seconds) - } + pollingInterval: 10000, // Polling interval during normal operation (10 seconds) + }, }; export const useServerData = () => { @@ -22,7 +23,7 @@ export const useServerData = () => { const [refreshKey, setRefreshKey] = useState(0); const [isInitialLoading, setIsInitialLoading] = useState(true); const [fetchAttempts, setFetchAttempts] = useState(0); - + // Timer reference for polling const intervalRef = useRef(null); // Track current attempt count to avoid dependency cycles @@ -40,17 +41,17 @@ export const useServerData = () => { const startNormalPolling = useCallback(() => { // Ensure no other timers are running clearTimer(); - + const fetchServers = async () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/servers', { + const response = await fetch(getApiUrl('/servers'), { headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); const data = await response.json(); - + if (data && data.success && Array.isArray(data.data)) { setServers(data.data); } else if (data && Array.isArray(data)) { @@ -59,29 +60,29 @@ export const useServerData = () => { console.error('Invalid server data format:', data); setServers([]); } - + // Reset error state setError(null); } catch (err) { console.error('Error fetching servers during normal polling:', err); - + // Use friendly error message if (!navigator.onLine) { setError(t('errors.network')); - } else if (err instanceof TypeError && ( - err.message.includes('NetworkError') || - err.message.includes('Failed to fetch') - )) { + } else if ( + err instanceof TypeError && + (err.message.includes('NetworkError') || err.message.includes('Failed to fetch')) + ) { setError(t('errors.serverConnection')); } else { setError(t('errors.serverFetch')); } } }; - + // Execute immediately fetchServers(); - + // Set up regular polling intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval); }, [t]); @@ -92,18 +93,18 @@ export const useServerData = () => { attemptsRef.current = 0; setFetchAttempts(0); } - + // Initialization phase request function const fetchInitialData = async () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/servers', { + const response = await fetch(getApiUrl('/servers'), { headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); const data = await response.json(); - + // Handle API response wrapper object, extract data field if (data && data.success && Array.isArray(data.data)) { setServers(data.data); @@ -131,17 +132,17 @@ export const useServerData = () => { // Increment attempt count, use ref to avoid triggering effect rerun attemptsRef.current += 1; console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err); - + // Update state for display setFetchAttempts(attemptsRef.current); - + // Set appropriate error message if (!navigator.onLine) { setError(t('errors.network')); } else { setError(t('errors.initialStartup')); } - + // If maximum attempt count is exceeded, give up initialization and switch to normal polling if (attemptsRef.current >= CONFIG.startup.maxAttempts) { console.log('Maximum startup attempts reached, switching to normal polling'); @@ -151,19 +152,19 @@ export const useServerData = () => { // Switch to normal polling mode startNormalPolling(); } - + return false; } }; - + // On component mount, set appropriate polling based on current state if (isInitialLoading) { // Ensure no other timers are running clearTimer(); - + // Execute initial request immediately fetchInitialData(); - + // Set polling interval for initialization phase intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval); console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`); @@ -171,7 +172,7 @@ export const useServerData = () => { // Initialization completed, start normal polling startNormalPolling(); } - + // Cleanup function return () => { clearTimer(); @@ -182,21 +183,21 @@ export const useServerData = () => { const triggerRefresh = () => { // Clear current timer clearTimer(); - + // If in initialization phase, reset initialization state if (isInitialLoading) { setIsInitialLoading(true); attemptsRef.current = 0; setFetchAttempts(0); } - + // Change in refreshKey will trigger useEffect to run again - setRefreshKey(prevKey => prevKey + 1); + setRefreshKey((prevKey) => prevKey + 1); }; // Server related operations const handleServerAdd = () => { - setRefreshKey(prevKey => prevKey + 1); + setRefreshKey((prevKey) => prevKey + 1); }; const handleServerEdit = async (server: Server) => { @@ -205,12 +206,12 @@ export const useServerData = () => { const token = localStorage.getItem('mcphub_token'); const response = await fetch(`/api/settings`, { headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); - + const settingsData: ApiResponse<{ mcpServers: Record }> = await response.json(); - + if ( settingsData && settingsData.success && @@ -243,8 +244,8 @@ export const useServerData = () => { const response = await fetch(`/api/servers/${serverName}`, { method: 'DELETE', headers: { - 'x-auth-token': token || '' - } + 'x-auth-token': token || '', + }, }); const result = await response.json(); @@ -253,7 +254,7 @@ export const useServerData = () => { return false; } - setRefreshKey(prevKey => prevKey + 1); + setRefreshKey((prevKey) => prevKey + 1); return true; } catch (err) { setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err))); @@ -268,7 +269,7 @@ export const useServerData = () => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-auth-token': token || '' + 'x-auth-token': token || '', }, body: JSON.stringify({ enabled }), }); @@ -282,7 +283,7 @@ export const useServerData = () => { } // Update the UI immediately to reflect the change - setRefreshKey(prevKey => prevKey + 1); + setRefreshKey((prevKey) => prevKey + 1); return true; } catch (err) { console.error('Error toggling server:', err); @@ -301,6 +302,6 @@ export const useServerData = () => { handleServerAdd, handleServerEdit, handleServerRemove, - handleServerToggle + handleServerToggle, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useSettingsData.ts b/frontend/src/hooks/useSettingsData.ts index 1926a74..0c20b3c 100644 --- a/frontend/src/hooks/useSettingsData.ts +++ b/frontend/src/hooks/useSettingsData.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ApiResponse } from '@/types'; import { useToast } from '@/contexts/ToastContext'; +import { getApiUrl } from '../utils/api'; // Define types for the settings data interface RoutingConfig { @@ -80,7 +81,7 @@ export const useSettingsData = () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/settings', { + const response = await fetch(getApiUrl('/settings'), { headers: { 'x-auth-token': token || '', }, @@ -136,7 +137,7 @@ export const useSettingsData = () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/system-config', { + const response = await fetch(getApiUrl('/system-config'), { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -183,7 +184,7 @@ export const useSettingsData = () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/system-config', { + const response = await fetch(getApiUrl('/system-config'), { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -233,7 +234,7 @@ export const useSettingsData = () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/system-config', { + const response = await fetch(getApiUrl('/system-config'), { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -283,7 +284,7 @@ export const useSettingsData = () => { try { const token = localStorage.getItem('mcphub_token'); - const response = await fetch('/api/system-config', { + const response = await fetch(getApiUrl('/system-config'), { method: 'PUT', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts index 0e1dd62..bf762df 100644 --- a/frontend/src/services/authService.ts +++ b/frontend/src/services/authService.ts @@ -1,7 +1,10 @@ -import { AuthResponse, LoginCredentials, RegisterCredentials, ChangePasswordCredentials } from '../types'; - -// Base URL for API requests -const API_URL = ''; +import { + AuthResponse, + LoginCredentials, + RegisterCredentials, + ChangePasswordCredentials, +} from '../types'; +import { getApiUrl } from '../utils/api'; // Token key in localStorage const TOKEN_KEY = 'mcphub_token'; @@ -24,7 +27,8 @@ export const removeToken = (): void => { // Login user export const login = async (credentials: LoginCredentials): Promise => { try { - const response = await fetch(`${API_URL}/auth/login`, { + console.log(getApiUrl('/auth/login')); + const response = await fetch(getApiUrl('/auth/login'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -33,11 +37,11 @@ export const login = async (credentials: LoginCredentials): Promise => { try { - const response = await fetch(`${API_URL}/auth/register`, { + const response = await fetch(getApiUrl('/auth/register'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -60,11 +64,11 @@ export const register = async (credentials: RegisterCredentials): Promise => { const token = getToken(); - + if (!token) { return { success: false, message: 'No authentication token', }; } - + try { - const response = await fetch(`${API_URL}/auth/user`, { + const response = await fetch(getApiUrl('/auth/user'), { method: 'GET', headers: { 'x-auth-token': token, @@ -105,18 +109,20 @@ export const getCurrentUser = async (): Promise => { }; // Change password -export const changePassword = async (credentials: ChangePasswordCredentials): Promise => { +export const changePassword = async ( + credentials: ChangePasswordCredentials, +): Promise => { const token = getToken(); - + if (!token) { return { success: false, message: 'No authentication token', }; } - + try { - const response = await fetch(`${API_URL}/auth/change-password`, { + const response = await fetch(getApiUrl('/auth/change-password'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -138,4 +144,4 @@ export const changePassword = async (credentials: ChangePasswordCredentials): Pr // Logout user export const logout = (): void => { removeToken(); -}; \ No newline at end of file +}; diff --git a/frontend/src/services/logService.ts b/frontend/src/services/logService.ts index bf60515..b4a07b6 100644 --- a/frontend/src/services/logService.ts +++ b/frontend/src/services/logService.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { getToken } from './authService'; // Import getToken function +import { getApiUrl } from '../utils/api'; export interface LogEntry { timestamp: number; @@ -18,18 +19,18 @@ export const fetchLogs = async (): Promise => { throw new Error('Authentication token not found. Please log in.'); } - const response = await fetch('/api/logs', { + const response = await fetch(getApiUrl('/logs'), { headers: { - 'x-auth-token': token - } + 'x-auth-token': token, + }, }); - + const result = await response.json(); - + if (!result.success) { throw new Error(result.error || 'Failed to fetch logs'); } - + return result.data; } catch (error) { console.error('Error fetching logs:', error); @@ -46,15 +47,15 @@ export const clearLogs = async (): Promise => { throw new Error('Authentication token not found. Please log in.'); } - const response = await fetch('/api/logs', { + const response = await fetch(getApiUrl('/logs'), { method: 'DELETE', headers: { - 'x-auth-token': token - } + 'x-auth-token': token, + }, }); - + const result = await response.json(); - + if (!result.success) { throw new Error(result.error || 'Failed to clear logs'); } @@ -90,19 +91,19 @@ export const useLogs = () => { } // Connect to SSE endpoint with auth token in URL - eventSource = new EventSource(`/api/logs/stream?token=${token}`); + eventSource = new EventSource(getApiUrl(`/logs/stream?token=${token}`)); eventSource.onmessage = (event) => { if (!isMounted) return; try { const data = JSON.parse(event.data); - + if (data.type === 'initial') { setLogs(data.logs); setLoading(false); } else if (data.type === 'log') { - setLogs(prevLogs => [...prevLogs, data.log]); + setLogs((prevLogs) => [...prevLogs, data.log]); } } catch (err) { console.error('Error parsing SSE message:', err); @@ -111,13 +112,13 @@ export const useLogs = () => { eventSource.onerror = () => { if (!isMounted) return; - + if (eventSource) { eventSource.close(); // Attempt to reconnect after a delay setTimeout(connectToLogStream, 5000); } - + setError(new Error('Connection to log stream lost, attempting to reconnect...')); }; } catch (err) { @@ -149,4 +150,4 @@ export const useLogs = () => { }; return { logs, loading, error, clearLogs: clearAllLogs }; -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts new file mode 100644 index 0000000..5fddcd3 --- /dev/null +++ b/frontend/src/utils/api.ts @@ -0,0 +1,27 @@ +/** + * API utility functions for constructing URLs with proper base path support + */ + +/** + * Get the API base URL including base path and /api prefix + * @returns The complete API base URL + */ +export const getApiBaseUrl = (): string => { + const basePath = import.meta.env.BASE_PATH || ''; + // Ensure the path starts with / if it's not empty and doesn't already start with / + const normalizedBasePath = basePath && !basePath.startsWith('/') ? '/' + basePath : basePath; + // Always append /api to the base path for API endpoints + return normalizedBasePath + '/api'; +}; + +/** + * Construct a full API URL with the given endpoint + * @param endpoint - The API endpoint (should start with /, e.g., '/auth/login') + * @returns The complete API URL + */ +export const getApiUrl = (endpoint: string): string => { + const baseUrl = getApiBaseUrl(); + // Ensure endpoint starts with / + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : '/' + endpoint; + return baseUrl + normalizedEndpoint; +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index c66c126..e58d261 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -3,7 +3,7 @@ interface ImportMeta { readonly env: { readonly PACKAGE_VERSION: string; - readonly VITE_BASE_PATH?: string; // Add base path environment variable + readonly BASE_PATH?: string; // Add base path environment variable // Add other custom env variables here if needed [key: string]: any; }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 39bd1f7..013c921 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,9 +8,12 @@ import { readFileSync } from 'fs'; // Get package.json version const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')); +// Get base path from environment variable +const basePath = process.env.BASE_PATH || ''; + // https://vitejs.dev/config/ export default defineConfig({ - base: './', // Use relative paths for assets + base: basePath || './', // Use base path or relative paths for assets plugins: [react(), tailwindcss()], resolve: { alias: { @@ -18,19 +21,20 @@ export default defineConfig({ }, }, define: { - // Make package version available as global variable + // Make package version and base path available as global variables 'import.meta.env.PACKAGE_VERSION': JSON.stringify(packageJson.version), + 'import.meta.env.BASE_PATH': JSON.stringify(basePath), }, build: { sourcemap: true, // Enable source maps for production build }, server: { proxy: { - '/api': { + [`${basePath}/api`]: { target: 'http://localhost:3000', changeOrigin: true, }, - '/auth': { + [`${basePath}/auth`]: { target: 'http://localhost:3000', changeOrigin: true, }, diff --git a/nginx.conf.example b/nginx.conf.example new file mode 100644 index 0000000..780a7f8 --- /dev/null +++ b/nginx.conf.example @@ -0,0 +1,72 @@ +# Nginx configuration example for MCPHub with subpath routing +# This example shows how to deploy MCPHub under a subpath like /mcphub + +server { + listen 80; + server_name your-domain.com; + + # MCPHub under /mcphub subpath + location /mcphub/ { + # Remove the subpath prefix before forwarding to MCPHub + rewrite ^/mcphub/(.*)$ /$1 break; + + proxy_pass http://mcphub:3000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # Important: Disable buffering for SSE connections + proxy_buffering off; + proxy_cache off; + + # Support for Server-Sent Events (SSE) + proxy_read_timeout 24h; + proxy_send_timeout 24h; + } + + # Alternative configuration if you want to keep the subpath in the backend + # In this case, set BASE_PATH=/mcphub + # location /mcphub/ { + # proxy_pass http://mcphub:3000/mcphub/; + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection 'upgrade'; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # proxy_cache_bypass $http_upgrade; + # + # # Important: Disable buffering for SSE connections + # proxy_buffering off; + # proxy_cache off; + # + # # Support for Server-Sent Events (SSE) + # proxy_read_timeout 24h; + # proxy_send_timeout 24h; + # } +} + +# Docker Compose example with subpath +# version: '3.8' +# services: +# mcphub: +# image: samanhappy/mcphub +# environment: +# - BASE_PATH=/mcphub +# volumes: +# - ./mcp_settings.json:/app/mcp_settings.json +# +# nginx: +# image: nginx:alpine +# ports: +# - "80:80" +# volumes: +# - ./nginx.conf:/etc/nginx/conf.d/default.conf +# depends_on: +# - mcphub diff --git a/src/config/index.ts b/src/config/index.ts index ec79f64..8bf1adc 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -10,6 +10,7 @@ const defaultConfig = { port: process.env.PORT || 3000, initTimeout: process.env.INIT_TIMEOUT || 300000, timeout: process.env.REQUEST_TIMEOUT || 60000, + basePath: process.env.BASE_PATH || '', mcpHubName: 'mcphub', mcpHubVersion: getPackageVersion(), }; diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts index a5023a3..76624ca 100644 --- a/src/middlewares/index.ts +++ b/src/middlewares/index.ts @@ -5,6 +5,7 @@ import { dirname } from 'path'; import fs from 'fs'; import { auth } from './auth.js'; import { initializeDefaultUser } from '../models/User.js'; +import config from '../config/index.js'; // Create __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url); @@ -17,13 +18,13 @@ const findFrontendPath = (): string => { if (fs.existsSync(devPath)) { return path.join(dirname(__dirname), 'frontend', 'dist'); } - + // Try npm/npx installed path (remove /dist directory) const npmPath = path.join(dirname(dirname(__dirname)), 'frontend', 'dist', 'index.html'); if (fs.existsSync(npmPath)) { return path.join(dirname(dirname(__dirname)), 'frontend', 'dist'); } - + // If none of the above paths exist, return the most reasonable default path and log a warning console.warn('Warning: Could not locate frontend files. Using default path.'); return path.join(dirname(__dirname), 'frontend', 'dist'); @@ -32,10 +33,10 @@ const findFrontendPath = (): string => { const frontendPath = findFrontendPath(); export const errorHandler = ( - err: Error, - _req: Request, - res: Response, - _next: NextFunction + err: Error, + _req: Request, + res: Response, + _next: NextFunction, ): void => { console.error('Unhandled error:', err); res.status(500).json({ @@ -46,10 +47,17 @@ export const errorHandler = ( export const initMiddlewares = (app: express.Application): void => { // Serve static files from the dynamically determined frontend path - app.use(express.static(frontendPath)); + // Note: Static files will be handled by the server directly, not here app.use((req, res, next) => { - if (req.path !== '/sse' && req.path !== '/messages') { + const basePath = config.basePath; + // Only apply JSON parsing for API and auth routes, not for SSE or message endpoints + if ( + req.path !== `${basePath}/sse` && + req.path !== `${basePath}/messages` && + !req.path.startsWith(`${basePath}/sse/`) && + !req.path.startsWith(`${basePath}/mcp/`) + ) { express.json()(req, res, next); } else { next(); @@ -57,16 +65,18 @@ export const initMiddlewares = (app: express.Application): void => { }); // Initialize default admin user if no users exist - initializeDefaultUser().catch(err => { + initializeDefaultUser().catch((err) => { console.error('Error initializing default user:', err); }); - // Protect all API routes with authentication middleware - app.use('/api', auth); - - app.get('/', (_req: Request, res: Response) => { - // Serve the frontend application - res.sendFile(path.join(frontendPath, 'index.html')); + // Protect API routes with authentication middleware, but exclude auth endpoints + app.use(`${config.basePath}/api`, (req, res, next) => { + // Skip authentication for login and register endpoints + if (req.path === '/auth/login' || req.path === '/auth/register') { + next(); + } else { + auth(req, res, next); + } }); app.use(errorHandler); diff --git a/src/routes/index.ts b/src/routes/index.ts index 94e3a90..9e29487 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import { check } from 'express-validator'; +import config from '../config/index.js'; import { getAllServers, getAllSettings, @@ -7,7 +8,7 @@ import { updateServer, deleteServer, toggleServer, - updateSystemConfig + updateSystemConfig, } from '../controllers/serverController.js'; import { getGroups, @@ -18,7 +19,7 @@ import { addServerToExistingGroup, removeServerFromExistingGroup, getGroupServers, - updateGroupServersBatch + updateGroupServersBatch, } from '../controllers/groupController.js'; import { getAllMarketServers, @@ -27,19 +28,10 @@ import { getAllMarketTags, searchMarketServersByQuery, getMarketServersByCategory, - getMarketServersByTag + getMarketServersByTag, } from '../controllers/marketController.js'; -import { - login, - register, - getCurrentUser, - changePassword -} from '../controllers/authController.js'; -import { - getAllLogs, - clearLogs, - streamLogs -} from '../controllers/logController.js'; +import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js'; +import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; import { auth } from '../middlewares/auth.js'; const router = express.Router(); @@ -53,7 +45,7 @@ export const initRoutes = (app: express.Application): void => { router.delete('/servers/:name', deleteServer); router.post('/servers/:name/toggle', toggleServer); router.put('/system-config', updateSystemConfig); - + // Group management routes router.get('/groups', getGroups); router.get('/groups/:id', getGroup); @@ -65,7 +57,7 @@ export const initRoutes = (app: express.Application): void => { router.get('/groups/:id/servers', getGroupServers); // New route for batch updating servers in a group router.put('/groups/:id/servers/batch', updateGroupServersBatch); - + // Market routes router.get('/market/servers', getAllMarketServers); router.get('/market/servers/search', searchMarketServersByQuery); @@ -74,33 +66,45 @@ export const initRoutes = (app: express.Application): void => { router.get('/market/categories/:category', getMarketServersByCategory); router.get('/market/tags', getAllMarketTags); router.get('/market/tags/:tag', getMarketServersByTag); - + // Log routes router.get('/logs', getAllLogs); router.delete('/logs', clearLogs); router.get('/logs/stream', streamLogs); - - // Auth routes (these will NOT be protected by auth middleware) - app.post('/auth/login', [ - check('username', 'Username is required').not().isEmpty(), - check('password', 'Password is required').not().isEmpty(), - ], login); - - app.post('/auth/register', [ - check('username', 'Username is required').not().isEmpty(), - check('password', 'Password must be at least 6 characters').isLength({ min: 6 }), - ], register); - - app.get('/auth/user', auth, getCurrentUser); - - // Add change password route - app.post('/auth/change-password', [ - auth, - check('currentPassword', 'Current password is required').not().isEmpty(), - check('newPassword', 'New password must be at least 6 characters').isLength({ min: 6 }), - ], changePassword); - app.use('/api', router); + // Auth routes - move to router instead of app directly + router.post( + '/auth/login', + [ + check('username', 'Username is required').not().isEmpty(), + check('password', 'Password is required').not().isEmpty(), + ], + login, + ); + + router.post( + '/auth/register', + [ + check('username', 'Username is required').not().isEmpty(), + check('password', 'Password must be at least 6 characters').isLength({ min: 6 }), + ], + register, + ); + + router.get('/auth/user', auth, getCurrentUser); + + // Add change password route + router.post( + '/auth/change-password', + [ + auth, + check('currentPassword', 'Current password is required').not().isEmpty(), + check('newPassword', 'New password must be at least 6 characters').isLength({ min: 6 }), + ], + changePassword, + ); + + app.use(`${config.basePath}/api`, router); }; export default router; diff --git a/src/server.ts b/src/server.ts index c68de4b..315bdf6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,10 +22,12 @@ export class AppServer { private app: express.Application; private port: number | string; private frontendPath: string | null = null; + private basePath: string; constructor() { this.app = express(); this.port = config.port; + this.basePath = config.basePath; } async initialize(): Promise { @@ -40,11 +42,11 @@ export class AppServer { initUpstreamServers() .then(() => { console.log('MCP server initialized successfully'); - this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res)); - this.app.post('/messages', handleSseMessage); - this.app.post('/mcp/:group?', handleMcpPostRequest); - this.app.get('/mcp/:group?', handleMcpOtherRequest); - this.app.delete('/mcp/:group?', handleMcpOtherRequest); + this.app.get(`${this.basePath}/sse/:group?`, (req, res) => handleSseConnection(req, res)); + this.app.post(`${this.basePath}/messages`, handleSseMessage); + this.app.post(`${this.basePath}/mcp/:group?`, handleMcpPostRequest); + this.app.get(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest); + this.app.delete(`${this.basePath}/mcp/:group?`, handleMcpOtherRequest); }) .catch((error) => { console.error('Error initializing MCP server:', error); @@ -66,17 +68,26 @@ export class AppServer { if (this.frontendPath) { console.log(`Serving frontend from: ${this.frontendPath}`); - this.app.use(express.static(this.frontendPath)); + // Serve static files with base path + this.app.use(this.basePath, express.static(this.frontendPath)); - // Add the wildcard route for SPA + // Add the wildcard route for SPA with base path if (fs.existsSync(path.join(this.frontendPath, 'index.html'))) { - this.app.get('*', (_req, res) => { + this.app.get(`${this.basePath}/*`, (_req, res) => { res.sendFile(path.join(this.frontendPath!, 'index.html')); }); + + // Also handle root redirect if base path is set + if (this.basePath) { + this.app.get('/', (_req, res) => { + res.redirect(this.basePath); + }); + } } } else { console.warn('Frontend dist directory not found. Server will run without frontend.'); - this.app.get('/', (_req, res) => { + const rootPath = this.basePath || '/'; + this.app.get(rootPath, (_req, res) => { res .status(404) .send('Frontend not found. MCPHub API is running, but the UI is not available.');