diff --git a/backend/index.js b/backend/index.js index 5f6c600e2f..f1984b8394 100644 --- a/backend/index.js +++ b/backend/index.js @@ -3,6 +3,7 @@ import { handleTracking } from './tracking.js'; import jsyaml from 'js-yaml'; import { proxyHandler, proxyRateLimiter } from './proxy.js'; import companionRouter from './companion/companionRouter'; +import communityRouter from './modules/communityRouter'; //import { requestLogger } from './utils/other'; //uncomment this to log the outgoing traffic const express = require('express'); @@ -94,12 +95,14 @@ if (isDocker) { // yup, order matters here serveMonaco(app); app.use('/backend/ai-chat', companionRouter); + app.use('/backend/modules', communityRouter); app.use('/backend', handleRequest); serveStaticApp(app, '/', '/core-ui'); } else { // Running in prod mode handleTracking(app); app.use('/backend/ai-chat', companionRouter); + app.use('/backend/modules', communityRouter); app.use('/backend', handleRequest); } diff --git a/backend/modules/communityRouter.js b/backend/modules/communityRouter.js new file mode 100644 index 0000000000..04183946b7 --- /dev/null +++ b/backend/modules/communityRouter.js @@ -0,0 +1,38 @@ +import express from 'express'; +import cors from 'cors'; +import jsyaml from 'js-yaml'; + +const router = express.Router(); +router.use(express.json()); +router.use(cors()); + +async function handleGetCommunityResource(req, res) { + const { link } = JSON.parse(req.body.toString()); + + // Validate that link is a string and a valid HTTPS URL, and restrict to allowed domains. + if (typeof link !== 'string') { + return res.status(400).json('Link must be a string.'); + } + + try { + const url = new URL(link); + // Only allow HTTPS protocol and restrict to specific trusted domains. + const allowedDomains = ['github.com']; + if ( + url.protocol !== 'https:' || + !allowedDomains.some(domain => url.hostname.endsWith(domain)) + ) { + return res.status(400).json('Invalid or untrusted link provided.'); + } else { + const response = await fetch(link); + const data = await response.text(); + res.json(jsyaml.loadAll(data)); + } + } catch (error) { + res.status(500).json(`Failed to fetch community resource. ${error}`); + } +} + +router.post('/community-resource', handleGetCommunityResource); + +export default router; diff --git a/src/components/KymaModules/KymaModulesList.js b/src/components/KymaModules/KymaModulesList.js index d721b93620..74b056f8b6 100644 --- a/src/components/KymaModules/KymaModulesList.js +++ b/src/components/KymaModules/KymaModulesList.js @@ -41,6 +41,7 @@ export default function KymaModulesList({ namespaced }) { installedCommunityModules, communityModulesLoading, setOpenedModuleIndex: setOpenedCommunityModuleIndex, + handleResourceDelete: handleCommunityModuleDelete, } = useContext(CommunityModuleContext); const [selectedEntry, setSelectedEntry] = useState(null); @@ -94,7 +95,7 @@ export default function KymaModulesList({ namespaced }) { modulesLoading={communityModulesLoading} namespaced={namespaced} setOpenedModuleIndex={setOpenedCommunityModuleIndex} - handleResourceDelete={handleResourceDelete} + handleResourceDelete={handleCommunityModuleDelete} customSelectedEntry={selectedEntry} setSelectedEntry={setSelectedEntry} /> diff --git a/src/components/KymaModules/components/ModulesDeleteBox.tsx b/src/components/KymaModules/components/ModulesDeleteBox.tsx index da3ab1bef0..f40241d7fd 100644 --- a/src/components/KymaModules/components/ModulesDeleteBox.tsx +++ b/src/components/KymaModules/components/ModulesDeleteBox.tsx @@ -14,6 +14,8 @@ import { fetchResourceCounts, generateAssociatedResourcesUrls, getAssociatedResources, + getCommunityResourceUrls, + getCommunityResources, getCRResource, handleItemClick, } from '../deleteModulesHelpers'; @@ -26,6 +28,7 @@ import { cloneDeep } from 'lodash'; import { KymaResourceType, ModuleTemplateListType } from '../support'; import { SetterOrUpdater } from 'recoil'; import { ColumnLayoutState } from 'state/columnLayoutAtom'; +import { usePost } from 'shared/hooks/BackendAPI/usePost'; type ModulesListDeleteBoxProps = { DeleteMessageBox: React.FC; @@ -33,8 +36,9 @@ type ModulesListDeleteBoxProps = { selectedModules: { name: string }[]; chosenModuleIndex: number | null; kymaResource: KymaResourceType; - kymaResourceState: KymaResourceType; + kymaResourceState?: KymaResourceType; detailsOpen: boolean; + isCommunity?: boolean; setLayoutColumn: SetterOrUpdater; handleModuleUninstall: () => void; setChosenModuleIndex: React.Dispatch>; @@ -50,6 +54,7 @@ export const ModulesDeleteBox = ({ kymaResource, kymaResourceState, detailsOpen, + isCommunity, setLayoutColumn, handleModuleUninstall, setChosenModuleIndex, @@ -62,10 +67,14 @@ export const ModulesDeleteBox = ({ const { clusterUrl, namespaceUrl } = useUrl(); const deleteResourceMutation = useDelete(); const fetchFn = useSingleGet(); + const post = usePost(); const [resourceCounts, setResourceCounts] = useState>({}); const [forceDeleteUrls, setForceDeleteUrls] = useState([]); const [crUrls, setCrUrls] = useState([]); + const [communityResourcesUrls, setCommunityResourcesUrls] = useState< + string[] + >([]); const [allowForceDelete, setAllowForceDelete] = useState(false); const [associatedResourceLeft, setAssociatedResourceLeft] = useState(false); @@ -99,16 +108,31 @@ export const ModulesDeleteBox = ({ selectedModules, kymaResource, moduleTemplates, + isCommunity, ); - const crUrl = await generateAssociatedResourcesUrls( - crUResources, - fetchFn, - clusterUrl, - getScope, - namespaceUrl, - navigate, - ); + const crUrl = isCommunity + ? getCommunityResourceUrls(crUResources) + : await generateAssociatedResourcesUrls( + crUResources, + fetchFn, + clusterUrl, + getScope, + namespaceUrl, + navigate, + ); + + if (isCommunity) { + const communityResources = await getCommunityResources( + chosenModuleIndex, + selectedModules, + kymaResource, + moduleTemplates, + post, + ); + const communityUrls = getCommunityResourceUrls(communityResources); + setCommunityResourcesUrls(communityUrls); + } setResourceCounts(counts); setForceDeleteUrls(urls); @@ -130,6 +154,61 @@ export const ModulesDeleteBox = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [resourceCounts, associatedResources]); + const deleteAllResources = () => { + if (allowForceDelete && forceDeleteUrls.length > 0) { + deleteAssociatedResources(deleteResourceMutation, forceDeleteUrls); + } + if (chosenModuleIndex != null) { + selectedModules.splice(chosenModuleIndex, 1); + } + if (!isCommunity && kymaResource) { + setKymaResourceState({ + ...kymaResource, + spec: { + ...kymaResource.spec, + modules: selectedModules, + }, + }); + handleModuleUninstall(); + setInitialUnchangedResource(cloneDeep(kymaResourceState)); + } + + if (detailsOpen) { + setLayoutColumn({ + layout: 'OneColumn', + startColumn: null, + midColumn: null, + endColumn: null, + }); + } + if (allowForceDelete && forceDeleteUrls.length > 0) { + deleteCrResources(deleteResourceMutation, crUrls); + } + }; + + const deleteCommunityResources = async () => { + if (allowForceDelete && forceDeleteUrls.length) { + // Delete associated resources. + await deleteAssociatedResources(deleteResourceMutation, forceDeleteUrls); + } + if (allowForceDelete && crUrls?.length) { + // Delete spec.data. + await deleteCrResources(deleteResourceMutation, crUrls); + } + if (allowForceDelete && communityResourcesUrls?.length) { + // Delete community resources. + await deleteCrResources(deleteResourceMutation, communityResourcesUrls); + } + if (detailsOpen) { + setLayoutColumn({ + layout: 'OneColumn', + startColumn: null, + midColumn: null, + endColumn: null, + }); + } + }; + return ( { - if (allowForceDelete && forceDeleteUrls.length > 0) { - deleteAssociatedResources(deleteResourceMutation, forceDeleteUrls); - } - if (chosenModuleIndex != null) { - selectedModules.splice(chosenModuleIndex, 1); - } - setKymaResourceState({ - ...kymaResource, - spec: { - ...kymaResource.spec, - modules: selectedModules, - }, - }); - handleModuleUninstall(); - setInitialUnchangedResource(cloneDeep(kymaResourceState)); - if (detailsOpen) { - setLayoutColumn({ - layout: 'OneColumn', - startColumn: null, - midColumn: null, - endColumn: null, - }); - } - if (allowForceDelete && forceDeleteUrls.length > 0) { - deleteCrResources(deleteResourceMutation, crUrls); + if (!isCommunity && kymaResource) { + deleteAllResources(); + } else if (isCommunity) { + deleteCommunityResources(); } }} /> diff --git a/src/components/KymaModules/deleteModulesHelpers.tsx b/src/components/KymaModules/deleteModulesHelpers.tsx index 425bedcf83..55eaff1af1 100644 --- a/src/components/KymaModules/deleteModulesHelpers.tsx +++ b/src/components/KymaModules/deleteModulesHelpers.tsx @@ -4,6 +4,7 @@ import { findModuleTemplate, ModuleTemplateListType, } from './support'; +import { PostFn } from 'shared/hooks/BackendAPI/usePost'; interface Counts { [key: string]: number; @@ -44,6 +45,7 @@ export const getCRResource = ( selectedModules: any, kymaResource: any, moduleTemplates: ModuleTemplateListType, + isCommunity: boolean = false, ) => { if (chosenModuleIndex == null) { return []; @@ -63,15 +65,54 @@ export const getCRResource = ( let resource: Resource | null = null; if (module?.spec?.data) { - resource = { - group: module.spec.data.apiVersion.split('/')[0], - version: module.spec.data.apiVersion.split('/')[1], - kind: module.spec.data.kind, - }; + resource = isCommunity + ? module?.spec?.data + : { + group: module.spec.data.apiVersion.split('/')[0], + version: module.spec.data.apiVersion.split('/')[1], + kind: module.spec.data.kind, + }; } return resource ? [resource] : []; }; +export const getCommunityResources = async ( + chosenModuleIndex: number | null, + selectedModules: any, + kymaResource: any, + moduleTemplates: ModuleTemplateListType, + post: PostFn, +) => { + if (chosenModuleIndex == null) { + return []; + } + const selectedModule = selectedModules[chosenModuleIndex]; + const moduleChannel = selectedModule?.channel || kymaResource?.spec?.channel; + const moduleVersion = + selectedModule?.version || + findModuleStatus(kymaResource, selectedModule?.name)?.version; + + const module = findModuleTemplate( + moduleTemplates, + selectedModule?.name, + moduleChannel, + moduleVersion, + ); + + const resources = (module?.spec as any)?.resources; + if (resources?.length) { + const yamlRes = await Promise.all( + resources.map(async (res: any) => { + if (res.link) { + return await postForCommunityResources(post, res.link); + } + }), + ); + return yamlRes.flat(); + } + return []; +}; + export const handleItemClick = async ( kind: string, group: string, @@ -235,3 +276,48 @@ export const deleteCrResources = async ( return 'Error while deleting Custom Resource'; } }; + +export default async function postForCommunityResources( + post: PostFn, + link: string, +) { + if (!link) { + console.error('No link provided for community resource'); + return false; + } + + try { + const response = await post('/modules/community-resource', { link }); + if (response?.length) { + return response; + } + console.error('Empty or invalid response:', response); + return false; + } catch (error) { + console.error('Error fetching data:', error); + return false; + } +} + +export const getCommunityResourceUrls = (resources: any) => { + if (!resources?.length) return []; + + return resources.map((resource: any) => { + if (!resource) return ''; + + const apiVersion = + resource?.apiVersion || `${resource?.group}/${resource?.version}`; + const resourceName = resource?.metadata?.name || resource?.name; + const resourceNamespace = + resource?.metadata?.namespace || resource?.namespace; + const api = apiVersion === 'v1' ? 'api' : 'apis'; + + return resourceNamespace + ? `/${api}/${apiVersion}/namespaces/${resourceNamespace}/${pluralize( + resource.kind, + ).toLowerCase()}/${resourceName}` + : `/${api}/${apiVersion}/${pluralize( + resource.kind || '', + ).toLowerCase()}/${resourceName}`; + }); +}; diff --git a/src/components/KymaModules/providers/CommunityModuleProvider.js b/src/components/KymaModules/providers/CommunityModuleProvider.js index 8caafb4509..c2033c4ca6 100644 --- a/src/components/KymaModules/providers/CommunityModuleProvider.js +++ b/src/components/KymaModules/providers/CommunityModuleProvider.js @@ -1,6 +1,12 @@ import { createContext, useContext, useEffect, useState } from 'react'; +import { t } from 'i18next'; import { useGetInstalledModules } from '../hooks'; import { ModuleTemplatesContext } from './ModuleTemplatesProvider'; +import { createPortal } from 'react-dom'; +import { ModulesDeleteBox } from '../components/ModulesDeleteBox'; +import { checkSelectedModule } from '../support'; +import { Button } from '@ui5/webcomponents-react'; +import { KymaModuleContext } from './KymaModuleProvider'; export const CommunityModuleContext = createContext({ setOpenedModuleIndex: () => {}, @@ -15,25 +21,46 @@ export function CommunityModuleContextProvider({ children, layoutState, showDeleteDialog, + DeleteMessageBox, + handleResourceDelete, + setLayoutColumn, }) { - const [, setOpenedModuleIndex] = useState(); - const [, setDetailsOpen] = useState(false); - - useEffect(() => { - if (layoutState?.layout) { - setDetailsOpen(layoutState?.layout !== 'OneColumn'); - } - }, [layoutState]); + const [openedModuleIndex, setOpenedModuleIndex] = useState(); + const [detailsOpen, setDetailsOpen] = useState(false); + const { kymaResource } = useContext(KymaModuleContext); const { moduleTemplatesLoading, communityModuleTemplates } = useContext( ModuleTemplatesContext, ); - const { installed: installedCommunityModules, loading: communityModulesLoading, } = useGetInstalledModules(communityModuleTemplates, moduleTemplatesLoading); + useEffect(() => { + if (layoutState?.layout) { + setDetailsOpen(layoutState?.layout !== 'OneColumn'); + } + }, [layoutState]); + + const getOpenedModuleIndex = (moduleIndex, activeModules) => { + const index = + moduleIndex ?? + // Find index of the selected module after a refresh or other case after which we have undefined. + activeModules?.findIndex(module => + checkSelectedModule(module, layoutState), + ); + return index > -1 ? index : undefined; + }; + + const deleteModuleButton = ( +
+ +
+ ); + return ( + {createPortal( + getOpenedModuleIndex(openedModuleIndex, installedCommunityModules) !== + undefined && + !communityModulesLoading && + !moduleTemplatesLoading && + showDeleteDialog && ( + + ), + document.body, + )} {children} ); diff --git a/src/components/KymaModules/providers/KymaModuleProvider.js b/src/components/KymaModules/providers/KymaModuleProvider.js index 47d5fde683..5080a36152 100644 --- a/src/components/KymaModules/providers/KymaModuleProvider.js +++ b/src/components/KymaModules/providers/KymaModuleProvider.js @@ -81,6 +81,16 @@ export function KymaModuleContextProvider({ ModuleTemplatesContext, ); + const getOpenedModuleIndex = (moduleIndex, activeModules) => { + const index = + moduleIndex ?? + // Find index of the selected module after a refresh or other case after which we have undefined. + activeModules?.findIndex(module => + checkSelectedModule(module, layoutState), + ); + return index > -1 ? index : undefined; + }; + const deleteModuleButton = (