diff --git a/README.md b/README.md index 08f72d86..583bd69b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ Easily create configurations for the Frank!Framework with the Flow Studio and Editor! +Please use profile on locally: +-Dspring.profiles.active=local +or with docker +-Dspring.profiles.active=cloud + ## Build from source Building the project requires Java, Maven, NodeJS, PNPM and Docker installed on your system. diff --git a/docker-compose.yml b/docker-compose.yml index 73953d5d..82c4acf1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,5 +4,7 @@ services: build: dockerfile: ./docker/Dockerfile context: . + environment: + SPRING_PROFILES_ACTIVE: cloud ports: - "8080:8080" diff --git a/docker/Dockerfile b/docker/Dockerfile index 374c3148..d5a2c93c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -13,4 +13,6 @@ COPY --chown=spring ./target/flow*.jar /app/flow.jar EXPOSE 8080 +ENV SPRING_PROFILES_ACTIVE=cloud + CMD ["java","-jar","/app/flow.jar"] diff --git a/pom.xml b/pom.xml index 29e021af..d07769c2 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ 3.0.0-SNAPSHOT 21 + 2.20.1 1.18.42 3.0.0 3.6.0 @@ -111,11 +112,21 @@ spring-boot-testcontainers test + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate6 + ${jackson.version} + org.apache.commons commons-compress ${apache.commons.version} + + org.eclipse.jgit + org.eclipse.jgit + 7.1.0.202411261347-r + org.apache.commons commons-lang3 diff --git a/src/main/frontend/app/components/datamapper/forms/add-field-form.tsx b/src/main/frontend/app/components/datamapper/forms/add-field-form.tsx index 27b87069..7832940c 100644 --- a/src/main/frontend/app/components/datamapper/forms/add-field-form.tsx +++ b/src/main/frontend/app/components/datamapper/forms/add-field-form.tsx @@ -41,7 +41,7 @@ function AddFieldForm({ fieldType, onSave, parents, formatDefinition, initialDat const propertyRules = format?.properties.find((a) => a.name == variableType) setDefaultValueRules(propertyRules?.rules) setDefaultValueInputType(propertyRules?.type) - }, [variableType]) + }, [fieldType, formatDefinition, variableType]) const isFormIncomplete = !variableType || !label diff --git a/src/main/frontend/app/components/directory-picker/directory-picker.tsx b/src/main/frontend/app/components/directory-picker/directory-picker.tsx new file mode 100644 index 00000000..a437e186 --- /dev/null +++ b/src/main/frontend/app/components/directory-picker/directory-picker.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useState } from 'react' +import FolderIcon from '/icons/solar/Folder.svg?react' +import { filesystemService } from '~/services/filesystem-service' +import type { FilesystemEntry } from '~/types/filesystem.types' +import { ApiError } from '~/utils/api' + +interface DirectoryPickerProperties { + isOpen: boolean + onSelect: (absolutePath: string) => void + onCancel: () => void + rootLabel?: string +} + +export default function DirectoryPicker({ + isOpen, + onSelect, + onCancel, + rootLabel = 'Computer', +}: Readonly) { + const [currentPath, setCurrentPath] = useState('') + const [entries, setEntries] = useState([]) + const [selectedEntry, setSelectedEntry] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const loadEntries = useCallback(async (path: string) => { + setLoading(true) + setError(null) + setSelectedEntry(null) + try { + const result = await filesystemService.browse(path) + setEntries(result) + setCurrentPath(path) + } catch (error_) { + const status = error_ instanceof ApiError ? error_.status : 0 + if (status === 403) { + setError('Access denied') + } else { + setError(error_ instanceof Error ? error_.message : 'Failed to load directories') + } + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (isOpen) { + setSelectedEntry(null) + loadEntries('') + } + }, [isOpen, loadEntries]) + + if (!isOpen) return null + + const isRoot = !currentPath + const canGoUp = !isRoot + + const handleNavigateUp = () => { + if (/^[a-zA-Z]:[/\\]?$/.test(currentPath) || currentPath === '/') { + loadEntries('') + return + } + const parentPath = currentPath.replace(/[\\/][^\\/]*$/, '') + if (!parentPath || parentPath === currentPath) { + loadEntries('') + } else if (/^[a-zA-Z]:$/.test(parentPath)) { + loadEntries(`${parentPath}\\`) + } else { + loadEntries(parentPath) + } + } + + const handleClick = (entry: FilesystemEntry) => { + setSelectedEntry(entry.path) + } + + const handleDoubleClick = (entry: FilesystemEntry) => { + loadEntries(entry.path) + } + + const activePath = selectedEntry ?? currentPath + + return ( +
+
+
+

Select Directory

+ +
+ +
+ + {currentPath || rootLabel} +
+ +
+ {loading &&

Loading...

} + {error &&

{error}

} + {!loading && !error && entries.length === 0 && ( +

No subdirectories

+ )} + {!loading && + !error && + entries.map((entry) => ( + + ))} +
+ +
+ + {activePath || 'Select a directory'} + +
+ + +
+
+
+
+ ) +} diff --git a/src/main/frontend/app/components/file-structure/editor-data-provider.ts b/src/main/frontend/app/components/file-structure/editor-data-provider.ts index 153ed5cd..a5afd510 100644 --- a/src/main/frontend/app/components/file-structure/editor-data-provider.ts +++ b/src/main/frontend/app/components/file-structure/editor-data-provider.ts @@ -5,12 +5,14 @@ import { sortChildren } from './tree-utilities' export interface FileNode { name: string path: string + projectRoot?: boolean } export interface FileTreeNode { name: string path: string type: 'FILE' | 'DIRECTORY' + projectRoot?: boolean children?: FileTreeNode[] } @@ -22,11 +24,11 @@ export default class EditorFilesDataProvider implements TreeDataProvider { constructor(projectName: string) { this.projectName = projectName - this.loadRoot() + void this.fetchAndBuildTree() } - /** Fetch root directory from backend and build the provider's data */ - private async loadRoot() { + /** Fetch file tree from backend and build the provider's data */ + private async fetchAndBuildTree() { try { if (!this.projectName) return @@ -40,27 +42,12 @@ export default class EditorFilesDataProvider implements TreeDataProvider { this.data['root'] = { index: 'root', - data: { name: tree.name, path: tree.path }, + data: { name: tree.name, path: tree.path, projectRoot: true }, isFolder: true, children: [], } - // Sort directories first, then files, both alphabetically - const sortedChildren = sortChildren(tree.children) - - for (const child of sortedChildren) { - const childIndex = `root/${child.name}` - - this.data[childIndex] = { - index: childIndex, - data: { name: child.name, path: child.path }, - isFolder: child.type === 'DIRECTORY', - children: child.type === 'DIRECTORY' ? [] : undefined, - } - - this.data['root'].children!.push(childIndex) - } - + this.data['root'].children = this.buildChildren('root', tree.children) this.loadedDirectories.add(tree.path) this.notifyListeners(['root']) } catch (error) { @@ -79,34 +66,35 @@ export default class EditorFilesDataProvider implements TreeDataProvider { const directory = await fetchDirectoryByPath(this.projectName, item.data.path) if (!directory) { console.warn('[EditorFilesDataProvider] Received empty directory from API') - this.data = {} return } - const sortedChildren = sortChildren(directory.children) - - const children: TreeItemIndex[] = [] + item.children = this.buildChildren(itemId, directory.children) + this.loadedDirectories.add(item.data.path) + this.notifyListeners([itemId]) + } catch (error) { + console.error('Failed to load directory', error) + } + } - for (const child of sortedChildren) { - const childIndex = `${itemId}/${child.name}` + private buildChildren(parentIndex: TreeItemIndex, children?: FileTreeNode[]): TreeItemIndex[] { + const sorted = sortChildren(children) + const childIds: TreeItemIndex[] = [] - this.data[childIndex] = { - index: childIndex, - data: { name: child.name, path: child.path }, - isFolder: child.type === 'DIRECTORY', - children: child.type === 'DIRECTORY' ? [] : undefined, - } + for (const child of sorted) { + const childIndex = `${parentIndex}/${child.name}` - children.push(childIndex) + this.data[childIndex] = { + index: childIndex, + data: { name: child.name, path: child.path }, + isFolder: child.type === 'DIRECTORY', + children: child.type === 'DIRECTORY' ? [] : undefined, } - item.children = children - - this.loadedDirectories.add(item.data.path) - this.notifyListeners([itemId]) - } catch (error) { - console.error('Failed to load directory', error) + childIds.push(childIndex) } + + return childIds } public async getAllItems(): Promise[]> { diff --git a/src/main/frontend/app/hooks/use-backend-folders.ts b/src/main/frontend/app/hooks/use-backend-folders.ts deleted file mode 100644 index d98e6b47..00000000 --- a/src/main/frontend/app/hooks/use-backend-folders.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useAsync } from './use-async' -import { fetchBackendFolders, fetchProjectRoot } from '~/services/project-service' - -interface BackendFoldersData { - folders: string[] - rootPath: string | null -} - -export function useBackendFolders(enabled: boolean) { - return useAsync( - async (signal) => { - const [folders, rootData] = await Promise.all([fetchBackendFolders(signal), fetchProjectRoot(signal)]) - return { folders, rootPath: rootData.rootPath } - }, - { enabled }, - ) -} diff --git a/src/main/frontend/app/hooks/use-projects.ts b/src/main/frontend/app/hooks/use-projects.ts index 9091a706..ec9e1e45 100644 --- a/src/main/frontend/app/hooks/use-projects.ts +++ b/src/main/frontend/app/hooks/use-projects.ts @@ -1,7 +1,7 @@ import { useAsync } from './use-async' -import { fetchProjects } from '~/services/project-service' -import type { Project } from '~/routes/projectlanding/project-landing' +import type { RecentProject } from '~/types/project.types' +import { fetchRecentProjects } from '~/services/recent-project-service' -export function useProjects() { - return useAsync((signal) => fetchProjects(signal)) +export function useRecentProjects() { + return useAsync((signal) => fetchRecentProjects(signal)) } diff --git a/src/main/frontend/app/root.tsx b/src/main/frontend/app/root.tsx index 525d3fbb..6bca4b45 100644 --- a/src/main/frontend/app/root.tsx +++ b/src/main/frontend/app/root.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' - +import { ToastContainer } from 'react-toastify' +import 'react-toastify/dist/ReactToastify.css' import type { Route } from './+types/root' import 'allotment/dist/style.css' import './app.css' @@ -40,7 +41,12 @@ function ThemeProvider({ children }: { children: React.ReactNode }) { document.documentElement.dataset.theme = theme }, [theme]) - return <>{children} + return ( + <> + {children} + + + ) } export function Layout({ children }: Readonly<{ children: React.ReactNode }>) { diff --git a/src/main/frontend/app/routes/app-layout.tsx b/src/main/frontend/app/routes/app-layout.tsx index 974f04b6..c80d97d5 100644 --- a/src/main/frontend/app/routes/app-layout.tsx +++ b/src/main/frontend/app/routes/app-layout.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react' import { useProjectStore, getStoredProjectName } from '~/stores/project-store' import { fetchProject } from '~/services/project-service' import LoadingSpinner from '~/components/loading-spinner' +import type { Project } from '~/types/project.types' import { Toast } from '~/components/toast' export default function AppLayout() { @@ -18,7 +19,7 @@ export default function AppLayout() { } fetchProject(storedName) - .then((fetched) => { + .then((fetched: Project) => { useProjectStore.getState().setProject(fetched) }) .catch(() => { diff --git a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx index ff765056..69ff1fc2 100644 --- a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx +++ b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx @@ -1,7 +1,7 @@ -import type { Project } from '../projectlanding/project-landing' import { useState } from 'react' import { useProjectStore } from '~/stores/project-store' import { createConfiguration } from '~/services/configuration-service' +import type { Project } from '~/types/project.types' interface AddConfigurationModalProperties { isOpen: boolean diff --git a/src/main/frontend/app/routes/datamapper/property-list.tsx b/src/main/frontend/app/routes/datamapper/property-list.tsx index aa5f968d..acd201c8 100644 --- a/src/main/frontend/app/routes/datamapper/property-list.tsx +++ b/src/main/frontend/app/routes/datamapper/property-list.tsx @@ -98,7 +98,7 @@ function PropertyList({ config, configDispatch }: PropertyListProperties) { setEditingMapping, openMapping, }) - }, []) //UseMemo is used here to ensure nodetype is not changed throughout rerenders. If the variable is update reactflow throws a warning in the console; + }, [flow, openMapping]) //UseMemo is used here to ensure nodetype is not changed throughout rerenders. If the variable is update reactflow throws a warning in the console; useEffect(() => { if (!reactFlowInstance) return @@ -117,7 +117,7 @@ function PropertyList({ config, configDispatch }: PropertyListProperties) { return () => { window.removeEventListener('resize', updateSize) } - }, [reactFlowInstance]) + }, [flow, reactFlowInstance]) useEffect(() => { if (!reactFlowInstance) return @@ -126,12 +126,12 @@ function PropertyList({ config, configDispatch }: PropertyListProperties) { type: 'SET_PROPERTY_DATA', payload: reactFlowInstance.toObject(), }) - }, [reactFlowNodes, edges]) + }, [reactFlowNodes, edges, reactFlowInstance, configDispatch]) //Updates the outer canvas whenever something is added useEffect(() => { setCanvasSize((size) => flow.updateCanvasSize(reactFlowNodes, size)) - }, [reactFlowNodes]) + }, [flow, reactFlowNodes]) useEffect(() => { if (!reactFlowInstance || initHasRun.current) return @@ -150,7 +150,7 @@ function PropertyList({ config, configDispatch }: PropertyListProperties) { flow.importMultipleSchematics(sourceSchematics) } clearFiles() - }, [reactFlowInstance]) + }, [clearFiles, config.propertyData.nodes, flow, reactFlowInstance, sourceSchematics, targetSchematic]) const onReactFlowNodeChange = useCallback( (changes: NodeChange[]) => setReactFlowNodes((nodes) => applyNodeChanges(changes, nodes) as Node[]), @@ -191,7 +191,7 @@ function PropertyList({ config, configDispatch }: PropertyListProperties) { } restoreFlow() - }, [setReactFlowNodes]) + }, [config.propertyData, flow]) function openMapping() { requestAnimationFrame(() => { diff --git a/src/main/frontend/app/routes/editor/editor.tsx b/src/main/frontend/app/routes/editor/editor.tsx index bfd1198e..d30d0949 100644 --- a/src/main/frontend/app/routes/editor/editor.tsx +++ b/src/main/frontend/app/routes/editor/editor.tsx @@ -1,6 +1,6 @@ import Editor, { type Monaco, type OnMount } from '@monaco-editor/react' import { useShallow } from 'zustand/react/shallow' -import { toast, ToastContainer } from 'react-toastify' +import { toast } from 'react-toastify' import SidebarHeader from '~/components/sidebars-layout/sidebar-header' import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' @@ -322,7 +322,6 @@ export default function CodeEditor() { onMount={handleEditorMount} options={{ automaticLayout: true, quickSuggestions: false }} /> - ) : ( diff --git a/src/main/frontend/app/routes/projectlanding/clone-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/clone-project-modal.tsx new file mode 100644 index 00000000..06ffec96 --- /dev/null +++ b/src/main/frontend/app/routes/projectlanding/clone-project-modal.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react' +import DirectoryPicker from '~/components/directory-picker/directory-picker' +import { filesystemService } from '~/services/filesystem-service' + +interface CloneProjectModalProperties { + isOpen: boolean + isLocal: boolean + onClose: () => void + onClone: (repoUrl: string, localPath: string) => void +} + +export default function CloneProjectModal({ + isOpen, + isLocal, + onClose, + onClone, +}: Readonly) { + const [repoUrl, setRepoUrl] = useState('') + const [location, setLocation] = useState('') + const [showPicker, setShowPicker] = useState(false) + + useEffect(() => { + if (isOpen && isLocal) { + filesystemService + .getDefaultPath() + .then(setLocation) + .catch(() => setLocation('')) + } else if (isOpen) { + setLocation('') + } + }, [isOpen, isLocal]) + + if (!isOpen) return null + + const repoName = repoUrl + .split('/') + .pop() + ?.replace(/\.git$/, '') + + const handleClone = () => { + if (!repoUrl.trim()) return + if (isLocal && !location) return + + let finalPath: string + + if (isLocal) { + const separator = location.includes('/') ? '/' : '\\' + finalPath = `${location}${separator}${repoName}` + } else { + const name = repoName || 'cloned-project' + finalPath = location ? `${location}/${name}` : name + } + + onClone(repoUrl.trim(), finalPath) + handleClose() + } + + const handleClose = () => { + setRepoUrl('') + setLocation('') + setShowPicker(false) + onClose() + } + + return ( + <> +
+
+

Clone Repository

+

+ {isLocal ? 'Clone a Git repository to a local folder' : 'Clone a Git repository into the workspace'} +

+ +
+ +
+ + + +
+
+ +
+ + setRepoUrl(event.target.value)} + className="border-border bg-background focus:border-foreground-active focus:ring-foreground-active w-full rounded border px-2 py-1 text-sm transition focus:ring-2 focus:outline-none" + placeholder="https://github.com/user/repo.git" + aria-label="repository url" + /> +
+ + {repoName && ( +

+ Will clone to:{' '} + {isLocal + ? `${location}${location.includes('/') ? '/' : '\\'}${repoName}` + : `${location ? `${location}/` : ''}${repoName}`} +

+ )} + +
+ + + +
+
+
+ + { + setLocation(path) + setShowPicker(false) + }} + onCancel={() => setShowPicker(false)} + /> + + ) +} diff --git a/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx deleted file mode 100644 index b6f97be1..00000000 --- a/src/main/frontend/app/routes/projectlanding/load-project-modal.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import FolderIcon from '../../../icons/solar/Folder.svg?react' -import { useBackendFolders } from '~/hooks/use-backend-folders' - -interface LoadProjectModalProperties { - isOpen: boolean - onClose: () => void - onCreate: (name: string, rootPath: string) => void -} - -export default function LoadProjectModal({ isOpen, onClose, onCreate }: Readonly) { - const { data, isLoading: loading, error: fetchError } = useBackendFolders(isOpen) - - const folders = data?.folders ?? [] - const rootPath = data?.rootPath ?? null - const error = fetchError ? fetchError.message : null - - if (!isOpen) return null - - const handleCreate = (name: string) => { - onCreate(name, rootPath || '') - onClose() - } - - return ( -
-
- {/* Header */} -
-

Load Project

- {rootPath &&

Root: {rootPath}

} -
- - {/* Content */} -
- {loading &&

Loading folders...

} - {error &&

{error}

} - {!loading && !error && folders.length === 0 &&

No folders found.

} - -
    - {folders.map((folder) => ( -
  • handleCreate(folder)} - className="hover:bg-backdrop flex items-center gap-2 rounded-md px-2 py-1 hover:cursor-pointer" - > - - {folder} -
  • - ))} -
-
- - {/* Close button */} - -
-
- ) -} diff --git a/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx b/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx index a58cf9ae..088a7d4c 100644 --- a/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx +++ b/src/main/frontend/app/routes/projectlanding/new-project-modal.tsx @@ -1,81 +1,130 @@ -import { useEffect, useState } from 'react' -import { fetchProjectRoot } from '~/services/project-service' +import { useState, useEffect } from 'react' +import DirectoryPicker from '~/components/directory-picker/directory-picker' +import { filesystemService } from '~/services/filesystem-service' interface NewProjectModalProperties { isOpen: boolean + isLocal: boolean onClose: () => void - onCreate: (name: string, rootPath: string) => void + onCreate: (pathOrName: string) => void } -export default function NewProjectModal({ isOpen, onClose, onCreate }: Readonly) { +export default function NewProjectModal({ isOpen, isLocal, onClose, onCreate }: Readonly) { const [name, setName] = useState('') - const [rootPath, setRootPath] = useState('') - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) + const [location, setLocation] = useState('') + const [showPicker, setShowPicker] = useState(false) useEffect(() => { - if (!isOpen) return - - const fetchData = async () => { - setLoading(true) - setError(null) - - try { - const rootData = await fetchProjectRoot() - setRootPath(rootData.rootPath) - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to fetch project data') - } finally { - setLoading(false) - } + if (isOpen && isLocal) { + filesystemService + .getDefaultPath() + .then(setLocation) + .catch(() => setLocation('')) + } else if (isOpen) { + setLocation('') } - - fetchData() - }, [isOpen]) + }, [isOpen, isLocal]) if (!isOpen) return null - const handleCreate = async () => { + const handleCreate = () => { if (!name.trim()) return - onCreate(name, rootPath) + if (isLocal && !location) return + + if (isLocal) { + const separator = location.includes('/') ? '/' : '\\' + const absolutePath = `${location}${separator}${name.trim()}` + onCreate(absolutePath) + } else { + const trimmedName = name.trim() + const path = location ? `${location}/${trimmedName}` : trimmedName + onCreate(path) + } + + handleClose() + } + + const handleClose = () => { + setName('') + setLocation('') + setShowPicker(false) onClose() } return ( -
-
-

Add Project

-

Add a new project in {rootPath}

- -
- {loading &&

Loading rootfolder...

} - {error &&

{error}

} - - setName(event.target.value)} - className="border-border bg-background focus:border-foreground-active focus:ring-foreground-active w-full rounded border px-2 py-1 text-sm transition focus:ring-2 focus:outline-none" - placeholder="Enter project name" - aria-label="project name" - /> -
+ <> +
+
+

New Project

+

+ {isLocal ? 'Create a new Frank! project on disk' : 'Create a new project in the workspace'} +

-
- - - +
+ +
+ + + +
+
+ +
+ + setName(event.target.value)} + className="border-border bg-background focus:border-foreground-active focus:ring-foreground-active w-full rounded border px-2 py-1 text-sm transition focus:ring-2 focus:outline-none" + placeholder="Enter project name" + /> +
+ + {name.trim() && ( +

+ Project will be created at:{' '} + {isLocal + ? `${location}${location.includes('/') ? '/' : '\\'}${name.trim()}` + : `${location ? `${location}/` : ''}${name.trim()}`} +

+ )} + +
+ + + +
-
+ + { + setLocation(path) + setShowPicker(false) + }} + onCancel={() => setShowPicker(false)} + /> + ) } diff --git a/src/main/frontend/app/routes/projectlanding/project-landing.tsx b/src/main/frontend/app/routes/projectlanding/project-landing.tsx index 93a1cfae..6e58a7cd 100644 --- a/src/main/frontend/app/routes/projectlanding/project-landing.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-landing.tsx @@ -1,124 +1,305 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback, useRef } from 'react' +import { useNavigate } from 'react-router' import FfIcon from '/icons/custom/ff!-icon.svg?react' import ArchiveIcon from '/icons/solar/Archive.svg?react' +import { toast } from 'react-toastify' +import { useRecentProjects } from '~/hooks/use-projects' +import { useProjectStore } from '~/stores/project-store' +import { + openProject, + createProject, + cloneProject, + exportProject, + importProjectFolder, +} from '~/services/project-service' + import ProjectRow from './project-row' import Search from '~/components/search/search' import ActionButton from './action-button' -import { useProjectStore } from '~/stores/project-store' -import { useLocation } from 'react-router' import NewProjectModal from './new-project-modal' -import LoadProjectModal from './load-project-modal' -import { useProjects } from '~/hooks/use-projects' -import { createProject as createProjectService } from '~/services/project-service' -import { toast, ToastContainer } from 'react-toastify' -import { useTheme } from '~/hooks/use-theme' -import React from 'react' - -export interface Project { - name: string - rootPath: string - filepaths: string[] - filters: Record // key = filter name (e.g. "HTTP"), value = true/false -} +import CloneProjectModal from './clone-project-modal' +import DirectoryPicker from '~/components/directory-picker/directory-picker' +import type { RecentProject } from '~/types/project.types' +import { fetchAppInfo } from '~/services/app-info-service' +import { removeRecentProject } from '~/services/recent-project-service' +import useTabStore from '~/stores/tab-store' +import useEditorTabStore from '~/stores/editor-tab-store' export default function ProjectLanding() { - const { data: projectsData, isLoading: loading, error: projectsError } = useProjects() - const [projects, setProjects] = useState([]) - const [search, setSearch] = useState('') - const [showNewProjectModal, setShowNewProjectModal] = useState(false) - const [showLoadProjectModal, setShowLoadProjectModal] = useState(false) - const [localError, setLocalError] = useState(null) - const theme = useTheme() + const navigate = useNavigate() + const { data: recentProjects, isLoading, error: apiError, refetch } = useRecentProjects() + const clearProjectState = useProjectStore((state) => state.clearProject) + const clearTabsState = useTabStore((state) => state.clearTabs) + const clearEditorTabsState = useEditorTabStore((state) => state.clearTabs) + const setProject = useProjectStore((state) => state.setProject) - const clearProject = useProjectStore((state) => state.clearProject) - const location = useLocation() + const [searchTerm, setSearchTerm] = useState('') + const [isModalOpen, setIsModalOpen] = useState(false) + const [isCloneModalOpen, setIsCloneModalOpen] = useState(false) + const [isOpenPickerOpen, setIsOpenPickerOpen] = useState(false) + const [isLocalEnvironment, setIsLocalEnvironment] = useState(true) + const [rootLocationName, setRootLocationName] = useState('Computer') + const [isOpeningProject, setIsOpeningProject] = useState(false) + const importInputRef = useRef(null) useEffect(() => { - if (projectsData) { - setProjects(projectsData) - } - }, [projectsData]) + clearProjectState() + clearEditorTabsState() + clearTabsState() + }, [clearEditorTabsState, clearProjectState, clearTabsState]) + + useEffect(() => { + const loadAppInfo = async () => { + try { + const info = await fetchAppInfo() + setIsLocalEnvironment(info.isLocal) - const error = localError || (projectsError ? projectsError.message : null) + if (info.workspaceRoot) { + setRootLocationName(info.workspaceRoot) + } + } catch { + toast.error('Failed to fetch app info, defaulting to local mode.') + } + } + loadAppInfo() + }, []) - // Reset project when landing on home page useEffect(() => { - clearProject() - }, [location.key, clearProject]) + if (apiError) { + toast.error(`Could not load in projects: ${apiError.message}`) + } + }, [apiError]) + + const handleOpenProject = useCallback( + async (rootPath: string) => { + setIsOpeningProject(true) + try { + const project = await openProject(rootPath) + setProject(project) + navigate(`/studio/${encodeURIComponent(project.name)}`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to open project') + } finally { + setIsOpeningProject(false) + } + }, + [navigate, setProject], + ) + + const onOpenFolder = async (selectedPath: string) => { + setIsOpenPickerOpen(false) + await handleOpenProject(selectedPath) + } + + const onCreateProject = async (absolutePath: string) => { + setIsOpeningProject(true) + try { + const project = await createProject(absolutePath) + setProject(project) + setIsModalOpen(false) + navigate(`/studio/${encodeURIComponent(project.name)}`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create project') + } finally { + setIsOpeningProject(false) + } + } + + const onCloneProject = async (repoUrl: string, localPath: string) => { + setIsOpeningProject(true) + try { + const project = await cloneProject(repoUrl, localPath) + setProject(project) + setIsCloneModalOpen(false) + navigate(`/studio/${encodeURIComponent(project.name)}`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to clone project from GitHub') + } finally { + setIsOpeningProject(false) + } + } - const createProject = async (projectName: string, rootPath?: string) => { + const onRemoveProject = async (rootPath: string) => { try { - // refresh the project list after creation - const newProject = await createProjectService(projectName, rootPath) - setProjects((previous) => [...previous, newProject]) - } catch (error_) { - toast.error(error_ instanceof Error ? error_.message : 'Failed to create project') + await removeRecentProject(rootPath) + refetch() + } catch { + toast.error('Failed to remove recent opened project') + } + } - console.error('Something went wrong loading the project:', error_) + // eslint-disable-next-line unicorn/consistent-function-scoping + const onExportProject = async (projectName: string) => { + try { + await exportProject(projectName) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to export project') } } - const handleOpenProject = async () => { - setShowLoadProjectModal(true) + const handleImportFolderChange = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + + setIsOpeningProject(true) + try { + const project = await importProjectFolder(files) + setProject(project) + refetch() + navigate(`/studio/${encodeURIComponent(project.name)}`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to import project') + } finally { + setIsOpeningProject(false) + } + + if (importInputRef.current) { + importInputRef.current.value = '' + } } - // Filter projects by search string (case-insensitive) - const filteredProjects = projects.filter((project) => project.name.toLowerCase().includes(search.toLowerCase())) + const projects = recentProjects ?? [] + const filteredProjects = projects.filter((project) => project.name.toLowerCase().includes(searchTerm.toLowerCase())) - if (loading) - return ( -
-

Loading projects...

-
- ) - if (error) return

Error: {error}

+ if (isLoading || isOpeningProject) return return (
- -
-
- -
Flow
-
-
-
- {/* Header row */} -
-
- Projects -
-
- setSearch(event.target.value)} /> -
-
- {/* Content row */} +
+ +
+ +
-
- setShowNewProjectModal(true)} /> - - console.log('Cloning project')} /> -
-
- {filteredProjects.map((project, index) => ( - - ))} -
+ setIsModalOpen(true)} + onOpenClick={() => setIsOpenPickerOpen(true)} + onCloneClick={() => setIsCloneModalOpen(true)} + onImportClick={() => importInputRef.current?.click()} + /> +
-
+ + {!isLocalEnvironment && ( +
+ Cloud workspace projects are automatically removed after 24 hours of inactivity. After you are done please + use the Export functionality in the landing page to download a backup of your project. +
+ )} + + setShowNewProjectModal(false)} - onCreate={createProject} + isOpen={isModalOpen} + onClose={() => setIsModalOpen(false)} + onCreate={onCreateProject} + isLocal={isLocalEnvironment} + /> + setIsCloneModalOpen(false)} + onClone={onCloneProject} /> - setShowLoadProjectModal(false)} - onCreate={createProject} + {!isLocalEnvironment && ( + + )} + setIsOpenPickerOpen(false)} + rootLabel={rootLocationName} />
) } + +const Header = () => ( +
+ +

Frank!Flow

+
+) + +const Sidebar = ({ + isLocal, + onNewClick, + onOpenClick, + onCloneClick, + onImportClick, +}: { + isLocal?: boolean + onNewClick: () => void + onOpenClick: () => void + onCloneClick: () => void + onImportClick: () => void +}) => ( + +) + +const ProjectList = ({ + projects, + isLocal, + onProjectClick, + onRemoveProject, + onExportProject, +}: { + projects: RecentProject[] + isLocal: boolean + onProjectClick: (rootPath: string) => void + onRemoveProject: (rootPath: string) => void + onExportProject: (projectName: string) => void +}) => ( +
+ {projects.length === 0 ? ( +

No projects found

+ ) : ( + projects.map((project) => ( + onProjectClick(project.rootPath)} + onRemove={() => onRemoveProject(project.rootPath)} + onExport={() => onExportProject(project.name)} + /> + )) + )} +
+) + +const Toolbar = ({ onSearchChange }: { onSearchChange: (val: string) => void }) => ( +
+
+ Recent +
+
+ onSearchChange(e.target.value)} /> +
+
+) + +const LoadingState = () => ( +
+ Initializing workspace... +
+) diff --git a/src/main/frontend/app/routes/projectlanding/project-row.tsx b/src/main/frontend/app/routes/projectlanding/project-row.tsx index 99a0ad07..caf09754 100644 --- a/src/main/frontend/app/routes/projectlanding/project-row.tsx +++ b/src/main/frontend/app/routes/projectlanding/project-row.tsx @@ -1,39 +1,52 @@ -import { useNavigate } from 'react-router' -import { useProjectStore } from '~/stores/project-store' -import KebabVerticalIcon from 'icons/solar/Kebab Vertical.svg?react' -import useTabStore from '~/stores/tab-store' -import type { Project } from '~/routes/projectlanding/project-landing' -import useEditorTabStore from '~/stores/editor-tab-store' +import CloseSquareIcon from 'icons/solar/Close Square.svg?react' +import ArchiveIcon from '/icons/solar/Archive.svg?react' +import type { RecentProject } from '~/types/project.types' interface ProjectRowProperties { - project: Project + project: RecentProject + isLocal: boolean + onClick: () => void + onRemove: () => void + onExport: () => void } -export default function ProjectRow({ project }: Readonly) { - const navigate = useNavigate() - - const setProject = useProjectStore((state) => state.setProject) - const clearTabs = useTabStore((state) => state.clearTabs) - const clearEditorTabs = useEditorTabStore((state) => state.clearTabs) - +export default function ProjectRow({ project, isLocal, onClick, onRemove, onExport }: Readonly) { return (
{ - setProject(project) - clearTabs() - clearEditorTabs() - navigate('/configurations') - }} + onClick={onClick} >
-
{project.name}
-

- {project.rootPath}\{project.name} -

+
{project.name}
+

{project.rootPath}

- +
+ {!isLocal && ( + + )} + + +
) } diff --git a/src/main/frontend/app/routes/studio/canvas/flow.tsx b/src/main/frontend/app/routes/studio/canvas/flow.tsx index 2dd75fc1..5556697d 100644 --- a/src/main/frontend/app/routes/studio/canvas/flow.tsx +++ b/src/main/frontend/app/routes/studio/canvas/flow.tsx @@ -30,8 +30,7 @@ import { exportFlowToXml } from '~/routes/studio/flow-to-xml-parser' import useNodeContextStore from '~/stores/node-context-store' import CreateNodeModal from '~/components/flow/create-node-modal' import { useProjectStore } from '~/stores/project-store' -import { toast, ToastContainer } from 'react-toastify' -import { useTheme } from '~/hooks/use-theme' +import { toast } from 'react-toastify' import { saveAdapter } from '~/services/adapter-service' import { cloneWithRemappedIds } from '~/utils/flow-utils' @@ -53,7 +52,6 @@ const selector = (state: FlowState) => ({ }) function FlowCanvas({ showNodeContextMenu }: Readonly<{ showNodeContextMenu: (b: boolean) => void }>) { - const theme = useTheme() const [loading, setLoading] = useState(false) const { isEditing, setIsEditing, setParentId, setDraggedName } = useNodeContextStore( useShallow((s) => ({ @@ -661,7 +659,6 @@ function FlowCanvas({ showNodeContextMenu }: Readonly<{ showNodeContextMenu: (b: - setShowModal(false)} diff --git a/src/main/frontend/app/routes/studio/studio.tsx b/src/main/frontend/app/routes/studio/studio.tsx index 91fed7ec..a7086813 100644 --- a/src/main/frontend/app/routes/studio/studio.tsx +++ b/src/main/frontend/app/routes/studio/studio.tsx @@ -11,15 +11,12 @@ import { SidebarSide } from '~/components/sidebars-layout/sidebar-layout-store' import SidebarLayout from '~/components/sidebars-layout/sidebar-layout' import useTabStore from '~/stores/tab-store' import { useShallow } from 'zustand/react/shallow' -import { ToastContainer } from 'react-toastify' -import { useTheme } from '~/hooks/use-theme' import CodeIcon from '/icons/solar/Code.svg?react' import { openInEditor } from '~/actions/navigationActions' import Button from '~/components/inputs/button' export default function Studio() { const [showNodeContext, setShowNodeContext] = useState(false) - const theme = useTheme() const nodeId = useNodeContextStore((state) => state.nodeId) const { activeTab, activeTabPath } = useTabStore( @@ -63,7 +60,6 @@ export default function Studio() {
- ) : (
diff --git a/src/main/frontend/app/services/app-info-service.ts b/src/main/frontend/app/services/app-info-service.ts new file mode 100644 index 00000000..74005e06 --- /dev/null +++ b/src/main/frontend/app/services/app-info-service.ts @@ -0,0 +1,10 @@ +import { apiFetch } from '~/utils/api' + +export interface AppInfo { + isLocal: boolean + workspaceRoot: string +} + +export async function fetchAppInfo(signal?: AbortSignal): Promise { + return apiFetch('/app-info', { signal }) +} diff --git a/src/main/frontend/app/services/configuration-service.ts b/src/main/frontend/app/services/configuration-service.ts index 1ab144c6..655807b5 100644 --- a/src/main/frontend/app/services/configuration-service.ts +++ b/src/main/frontend/app/services/configuration-service.ts @@ -1,5 +1,5 @@ import { apiFetch } from '~/utils/api' -import type { Project } from '~/routes/projectlanding/project-landing' +import type { Project } from '~/types/project.types' export async function fetchConfiguration(projectName: string, filepath: string, signal?: AbortSignal): Promise { const data = await apiFetch<{ content: string }>(`/projects/${encodeURIComponent(projectName)}/configuration`, { diff --git a/src/main/frontend/app/services/filesystem-service.ts b/src/main/frontend/app/services/filesystem-service.ts new file mode 100644 index 00000000..4e36bc7b --- /dev/null +++ b/src/main/frontend/app/services/filesystem-service.ts @@ -0,0 +1,13 @@ +import { apiFetch } from '~/utils/api' +import type { FilesystemEntry } from '~/types/filesystem.types' + +export const filesystemService = { + async browse(path = ''): Promise { + return apiFetch(`/filesystem/browse?path=${encodeURIComponent(path)}`) + }, + + async getDefaultPath(): Promise { + const result = await apiFetch<{ path: string }>('/filesystem/default-path') + return result.path + }, +} diff --git a/src/main/frontend/app/services/project-service.ts b/src/main/frontend/app/services/project-service.ts index 2f440c00..4069abbf 100644 --- a/src/main/frontend/app/services/project-service.ts +++ b/src/main/frontend/app/services/project-service.ts @@ -1,46 +1,30 @@ -import { apiFetch } from '~/utils/api' -import type { Project } from '~/routes/projectlanding/project-landing' +import { apiFetch, apiUrl } from '~/utils/api' import type { FileTreeNode } from '~/routes/configurations/configuration-manager' +import type { Project } from '~/types/project.types' -export interface ConfigImport { - filepath: string - xmlContent: string +export async function fetchProject(name: string): Promise { + return apiFetch(`/projects/${encodeURIComponent(name)}`) } -export async function fetchProjects(signal?: AbortSignal): Promise { - return apiFetch('/projects', { signal }) -} - -export async function fetchProject(name: string, signal?: AbortSignal): Promise { - return apiFetch(`/projects/${encodeURIComponent(name)}`, { signal }) -} - -export async function createProject(name: string, rootPath?: string): Promise { - return apiFetch('/projects', { +export async function openProject(rootPath: string): Promise { + return apiFetch('/projects/open', { method: 'POST', - body: JSON.stringify({ - name, - rootPath: rootPath ?? undefined, - }), + body: JSON.stringify({ rootPath }), }) } -export async function importConfigurations(projectName: string, configs: ConfigImport[]): Promise { - await apiFetch(`/projects/${encodeURIComponent(projectName)}/import-configurations`, { +export async function cloneProject(repoUrl: string, localPath: string): Promise { + return apiFetch('/projects/clone', { method: 'POST', - body: JSON.stringify({ - projectName, - configurations: configs, - }), + body: JSON.stringify({ repoUrl, localPath }), }) } -export async function fetchBackendFolders(signal?: AbortSignal): Promise { - return apiFetch('/projects/backend-folders', { signal }) -} - -export async function fetchProjectRoot(signal?: AbortSignal): Promise<{ rootPath: string }> { - return apiFetch<{ rootPath: string }>('/projects/root', { signal }) +export async function createProject(rootPath: string): Promise { + return apiFetch('/projects', { + method: 'POST', + body: JSON.stringify({ rootPath }), + }) } export async function fetchProjectTree(projectName: string, signal?: AbortSignal): Promise { @@ -67,3 +51,41 @@ export async function toggleProjectFilter(projectName: string, filter: string, e method: 'PATCH', }) } + +export async function exportProject(projectName: string): Promise { + const url = apiUrl(`/projects/${encodeURIComponent(projectName)}/export`) + const sessionId = sessionStorage.getItem('frankflow_anon_session_id') ?? '' + + const headers: Record = { 'X-Session-ID': sessionId } + const token = localStorage.getItem('access_token') + if (token) headers['Authorization'] = `Bearer ${token}` + + const response = await fetch(url, { headers }) + if (!response.ok) throw new Error('Export failed') + + const blob = await response.blob() + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `${projectName}.zip` + a.click() + URL.revokeObjectURL(a.href) +} + +export async function importProjectFolder(files: FileList): Promise { + const formData = new FormData() + + const firstPath = files[0].webkitRelativePath + const projectName = firstPath.split('/')[0] + formData.append('projectName', projectName) + + for (const file of files) { + formData.append('files', file) + const relativePath = file.webkitRelativePath.split('/').slice(1).join('/') + formData.append('paths', relativePath) + } + + return apiFetch('/projects/import', { + method: 'POST', + body: formData, + }) +} diff --git a/src/main/frontend/app/services/recent-project-service.ts b/src/main/frontend/app/services/recent-project-service.ts new file mode 100644 index 00000000..b4ee30ff --- /dev/null +++ b/src/main/frontend/app/services/recent-project-service.ts @@ -0,0 +1,13 @@ +import type { RecentProject } from '~/types/project.types' +import { apiFetch } from '~/utils/api' + +export async function fetchRecentProjects(signal?: AbortSignal): Promise { + return apiFetch('/recent-projects', { signal }) +} + +export async function removeRecentProject(rootPath: string): Promise { + await apiFetch('/recent-projects', { + method: 'DELETE', + body: JSON.stringify({ rootPath }), + }) +} diff --git a/src/main/frontend/app/stores/project-store.ts b/src/main/frontend/app/stores/project-store.ts index 8774724d..519f0fb3 100644 --- a/src/main/frontend/app/stores/project-store.ts +++ b/src/main/frontend/app/stores/project-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { Project } from '~/routes/projectlanding/project-landing' +import type { Project } from '~/types/project.types' const SESSION_KEY = 'active-project-name' diff --git a/src/main/frontend/app/types/filesystem.types.ts b/src/main/frontend/app/types/filesystem.types.ts new file mode 100644 index 00000000..3d9b7256 --- /dev/null +++ b/src/main/frontend/app/types/filesystem.types.ts @@ -0,0 +1,8 @@ +export type EntryType = 'DIRECTORY' | 'FILE' + +export interface FilesystemEntry { + name: string + path: string + type: EntryType + projectRoot: boolean +} diff --git a/src/main/frontend/app/types/global.d.ts b/src/main/frontend/app/types/global.d.ts new file mode 100644 index 00000000..ca8d348b --- /dev/null +++ b/src/main/frontend/app/types/global.d.ts @@ -0,0 +1,3 @@ +interface Window { + showDirectoryPicker(): Promise +} diff --git a/src/main/frontend/app/types/project.types.ts b/src/main/frontend/app/types/project.types.ts new file mode 100644 index 00000000..7f451d45 --- /dev/null +++ b/src/main/frontend/app/types/project.types.ts @@ -0,0 +1,16 @@ +export interface Project { + name: string + rootPath: string + filepaths: string[] + filters: Record +} + +export interface ProjectCreateDTO { + rootPath: string +} + +export interface RecentProject { + name: string + rootPath: string + lastOpened: string +} diff --git a/src/main/frontend/app/utils/api.ts b/src/main/frontend/app/utils/api.ts index e5596828..a7436941 100644 --- a/src/main/frontend/app/utils/api.ts +++ b/src/main/frontend/app/utils/api.ts @@ -4,6 +4,20 @@ export function apiUrl(path: string): string { return `${variables.apiBaseUrl}/api${path}` } +const getAnonymousSessionId = () => { + const STORAGE_KEY = 'frankflow_anon_session_id' + let id = sessionStorage.getItem(STORAGE_KEY) + if (!id) { + id = crypto.randomUUID() + sessionStorage.setItem(STORAGE_KEY, id) + } + return id +} + +const getAuthToken = () => { + return localStorage.getItem('access_token') || null +} + interface BackendErrorResponse { httpStatus: number messages: string[] @@ -22,9 +36,26 @@ export class ApiError extends Error { } export async function apiFetch(path: string, options?: RequestInit): Promise { - const headers: HeadersInit = options?.body ? { 'Content-Type': 'application/json' } : {} + const isFormData = options?.body instanceof FormData + + const defaultHeaders: Record = + options?.body && !isFormData ? { 'Content-Type': 'application/json' } : {} + + const headers: Record = { + ...defaultHeaders, + 'X-Session-ID': getAnonymousSessionId(), + ...(options?.headers as Record), + } + + const token = getAuthToken() + if (token) { + headers['Authorization'] = `Bearer ${token}` + } - const response = await fetch(apiUrl(path), { ...options, headers: { ...headers, ...options?.headers } }) + const response = await fetch(apiUrl(path), { + ...options, + headers, + }) if (!response.ok) { const contentType = response.headers.get('content-type') diff --git a/src/main/frontend/icons/solar/Close Square.svg b/src/main/frontend/icons/solar/Close Square.svg index e71a46c6..addf87ad 100644 --- a/src/main/frontend/icons/solar/Close Square.svg +++ b/src/main/frontend/icons/solar/Close Square.svg @@ -1,4 +1,4 @@ - + diff --git a/src/main/java/org/frankframework/flow/FlowApplication.java b/src/main/java/org/frankframework/flow/FlowApplication.java index 55cff6c3..62940d39 100644 --- a/src/main/java/org/frankframework/flow/FlowApplication.java +++ b/src/main/java/org/frankframework/flow/FlowApplication.java @@ -6,13 +6,16 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.context.annotation.Bean; import org.springframework.core.io.ClassPathResource; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.web.servlet.function.RequestPredicate; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.ServerResponse; @SpringBootApplication +@EnableScheduling public class FlowApplication { public static void main(String[] args) { @@ -21,7 +24,7 @@ public static void main(String[] args) { } public static SpringApplication configureApplication() { - return new SpringApplication(FlowApplication.class); + return new SpringApplicationBuilder(FlowApplication.class).build(); } /** diff --git a/src/main/java/org/frankframework/flow/appinfo/AppInfoController.java b/src/main/java/org/frankframework/flow/appinfo/AppInfoController.java new file mode 100644 index 00000000..a31da971 --- /dev/null +++ b/src/main/java/org/frankframework/flow/appinfo/AppInfoController.java @@ -0,0 +1,26 @@ +package org.frankframework.flow.appinfo; + +import java.util.Map; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/app-info") +public class AppInfoController { + private final FileSystemStorage fileSystemStorage; + + public AppInfoController(FileSystemStorage fileSystemStorage) { + this.fileSystemStorage = fileSystemStorage; + } + + @GetMapping + public Map getInfo() { + return Map.of( + "isLocal", + fileSystemStorage.isLocalEnvironment(), + "workspaceRoot", + fileSystemStorage.isLocalEnvironment() ? "Computer" : "Cloud Workspace"); + } +} diff --git a/src/main/java/org/frankframework/flow/config/ApiPrefixConfiguration.java b/src/main/java/org/frankframework/flow/common/config/ApiPrefixConfiguration.java similarity index 92% rename from src/main/java/org/frankframework/flow/config/ApiPrefixConfiguration.java rename to src/main/java/org/frankframework/flow/common/config/ApiPrefixConfiguration.java index ef627f47..77627686 100644 --- a/src/main/java/org/frankframework/flow/config/ApiPrefixConfiguration.java +++ b/src/main/java/org/frankframework/flow/common/config/ApiPrefixConfiguration.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.config; +package org.frankframework.flow.common.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.bind.annotation.RestController; diff --git a/src/main/java/org/frankframework/flow/config/CorsConfig.java b/src/main/java/org/frankframework/flow/common/config/CorsConfig.java similarity index 94% rename from src/main/java/org/frankframework/flow/config/CorsConfig.java rename to src/main/java/org/frankframework/flow/common/config/CorsConfig.java index ba44eef7..9687da83 100644 --- a/src/main/java/org/frankframework/flow/config/CorsConfig.java +++ b/src/main/java/org/frankframework/flow/common/config/CorsConfig.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.config; +package org.frankframework.flow.common.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/org/frankframework/flow/common/config/MapperConfiguration.java b/src/main/java/org/frankframework/flow/common/config/MapperConfiguration.java new file mode 100644 index 00000000..01b4a245 --- /dev/null +++ b/src/main/java/org/frankframework/flow/common/config/MapperConfiguration.java @@ -0,0 +1,13 @@ +package org.frankframework.flow.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MapperConfiguration { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} diff --git a/src/main/java/org/frankframework/flow/config/RestTemplateConfig.java b/src/main/java/org/frankframework/flow/common/config/RestTemplateConfig.java similarity index 86% rename from src/main/java/org/frankframework/flow/config/RestTemplateConfig.java rename to src/main/java/org/frankframework/flow/common/config/RestTemplateConfig.java index d2af4a06..670d0850 100644 --- a/src/main/java/org/frankframework/flow/config/RestTemplateConfig.java +++ b/src/main/java/org/frankframework/flow/common/config/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package org.frankframework.flow.config; +package org.frankframework.flow.common.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/org/frankframework/flow/common/mapper/Mapper.java b/src/main/java/org/frankframework/flow/common/mapper/Mapper.java new file mode 100644 index 00000000..7a13571b --- /dev/null +++ b/src/main/java/org/frankframework/flow/common/mapper/Mapper.java @@ -0,0 +1,49 @@ +package org.frankframework.flow.common.mapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class Mapper { + private final ObjectMapper objectMapper; + + public Mapper(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public E toEntity(D dto, Class entityClass) { + return objectMapper.convertValue(dto, entityClass); + } + + public D toDTO(E entity, Class dtoClass) { + return objectMapper.convertValue(entity, dtoClass); + } + + public Set toEntity(Set dtoSet, Class entityClass) throws MappingException { + try { + log.info("Converting {} DTOs to Entities [{}]", dtoSet.size(), entityClass.getSimpleName()); + Set entities = + dtoSet.stream().map(dto -> toEntity(dto, entityClass)).collect(Collectors.toSet()); + log.info("Successfully mapped {} DTOs to Entities [{}]", dtoSet.size(), entityClass.getSimpleName()); + return entities; + } catch (Exception e) { + throw new MappingException("Failed to convert DTOs to Entities: " + e.getMessage(), e); + } + } + + public Set toDTO(Set entitySet, Class dtoClass) throws MappingException { + try { + log.info("Converting {} Entities to DTOs [{}]", entitySet.size(), dtoClass.getSimpleName()); + Set dtos = + entitySet.stream().map(entity -> toDTO(entity, dtoClass)).collect(Collectors.toSet()); + log.info("Successfully mapped {} Entities to DTOs [{}]", entitySet.size(), dtoClass.getSimpleName()); + return dtos; + } catch (Exception e) { + throw new MappingException("Failed to convert Entities to DTOs: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/org/frankframework/flow/common/mapper/MappingException.java b/src/main/java/org/frankframework/flow/common/mapper/MappingException.java new file mode 100644 index 00000000..113405f0 --- /dev/null +++ b/src/main/java/org/frankframework/flow/common/mapper/MappingException.java @@ -0,0 +1,10 @@ +package org.frankframework.flow.common.mapper; + +import org.frankframework.flow.exception.ApiException; +import org.springframework.http.HttpStatus; + +public class MappingException extends ApiException { + public MappingException(String message, Throwable cause) { + super(message, HttpStatus.BAD_REQUEST, cause); + } +} diff --git a/src/main/java/org/frankframework/flow/exception/GlobalExceptionHandler.java b/src/main/java/org/frankframework/flow/exception/GlobalExceptionHandler.java index 453c6716..a2dc97a5 100644 --- a/src/main/java/org/frankframework/flow/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/frankframework/flow/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package org.frankframework.flow.exception; import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -43,4 +44,21 @@ public ResponseEntity handleIllegalArgumentException( return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } + + @ExceptionHandler(IOException.class) + public ResponseEntity handleIOException(IOException exception, HttpServletRequest request) { + log.error( + "I/O error: {} - Method: {} URL: {}", + exception.getMessage(), + request.getMethod(), + request.getRequestURI(), + exception); + + ErrorResponse response = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + List.of("A filesystem error occurred: " + exception.getMessage()), + HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } } diff --git a/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java new file mode 100644 index 00000000..c56e5389 --- /dev/null +++ b/src/main/java/org/frankframework/flow/filesystem/CloudFileSystemStorageService.java @@ -0,0 +1,153 @@ +package org.frankframework.flow.filesystem; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.frankframework.flow.security.UserWorkspaceContext; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +@Service +@Profile("cloud") +@Slf4j +public class CloudFileSystemStorageService implements FileSystemStorage { + @Value("${frankflow.workspace.root:/tmp/frankflow/workspace}") + private String baseWorkspacePath; + + private final UserWorkspaceContext userContext; + + public CloudFileSystemStorageService(UserWorkspaceContext userContext) { + this.userContext = userContext; + } + + private Path getUserRootPath() { + String workspaceId = userContext.getWorkspaceId(); + if (workspaceId == null) workspaceId = "anonymous"; + + return Paths.get(baseWorkspacePath, workspaceId).toAbsolutePath().normalize(); + } + + private Path getOrCreateUserRoot() throws IOException { + Path userRoot = getUserRootPath(); + + if (!Files.exists(userRoot)) { + Files.createDirectories(userRoot); + } + + try { + Files.setLastModifiedTime(userRoot, FileTime.from(Instant.now())); + } catch (IOException e) { + log.debug("Could not touch workspace dir", e); + } + + return userRoot; + } + + @Override + public boolean isLocalEnvironment() { + return false; + } + + @Override + public List listRoots() { + try { + return listDirectory(""); + } catch (IOException e) { + log.warn("Error listing workspace root", e); + return Collections.emptyList(); + } + } + + @Override + public List listDirectory(String path) throws IOException { + Path userRoot = getOrCreateUserRoot(); + Path dir = resolveSecurely(path); + + List entries = new ArrayList<>(); + + try (Stream stream = Files.list(dir)) { + stream.filter(Files::isDirectory).sorted().forEach(p -> { + String relativePath = + userRoot.relativize(p.toAbsolutePath()).toString().replace("\\", "/"); + boolean isProjectRoot = Files.isDirectory(p.resolve("src/main/configurations")); + + entries.add(new FilesystemEntry(p.getFileName().toString(), relativePath, "DIRECTORY", isProjectRoot)); + }); + } + return entries; + } + + @Override + public String readFile(String path) throws IOException { + return Files.readString(resolveSecurely(path), StandardCharsets.UTF_8); + } + + @Override + public void writeFile(String path, String content) throws IOException { + Files.writeString(resolveSecurely(path), content, StandardCharsets.UTF_8); + } + + @Override + public Path createProjectDirectory(String path) throws IOException { + Path projectDir = resolveSecurely(path); + Files.createDirectories(projectDir); + return projectDir; + } + + @Override + public Path toAbsolutePath(String path) throws IOException { + return resolveSecurely(path); + } + + @Override + public String toRelativePath(String absolutePath) { + String normalized = absolutePath.replace("\\", "/"); + String userRoot = getUserRootPath().toString().replace("\\", "/"); + if (normalized.startsWith(userRoot)) { + String relative = normalized.substring(userRoot.length()); + if (relative.isEmpty()) return "/"; + if (!relative.startsWith("/")) relative = "/" + relative; + return relative; + } + return normalized; + } + + private Path resolveSecurely(String path) throws IOException { + Path root = getOrCreateUserRoot(); + + if (path == null || path.isBlank() || path.equals("/") || path.equals("\\")) { + return root; + } + + Path candidate = Paths.get(path).toAbsolutePath().normalize(); + if (candidate.startsWith(root)) { + return candidate; + } + + String cleanPath = path; + while (cleanPath.startsWith("/") || cleanPath.startsWith("\\")) { + cleanPath = cleanPath.substring(1); + } + + if (cleanPath.isBlank()) { + return root; + } + + Path resolved = root.resolve(cleanPath).normalize(); + + if (!resolved.startsWith(root)) { + throw new SecurityException("Access denied: " + path); + } + return resolved; + } +} diff --git a/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java new file mode 100644 index 00000000..a0db36ad --- /dev/null +++ b/src/main/java/org/frankframework/flow/filesystem/FileSystemStorage.java @@ -0,0 +1,39 @@ +package org.frankframework.flow.filesystem; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +public interface FileSystemStorage { + boolean isLocalEnvironment(); + + /** + * Returns root folders of environment. + */ + List listRoots(); + + /** + * Returns what directory entails + */ + List listDirectory(String path) throws IOException; + + String readFile(String path) throws IOException; + + void writeFile(String path, String content) throws IOException; + + /** + * Makes new folder in directory + */ + Path createProjectDirectory(String path) throws IOException; + + Path toAbsolutePath(String path) throws IOException; + + /** + * Strips the workspace root prefix from a path. + * Local: returns the path unchanged. + * Cloud: returns the path relative to the user's workspace root. + */ + default String toRelativePath(String absolutePath) { + return absolutePath; + } +} diff --git a/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java b/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java new file mode 100644 index 00000000..42a391bf --- /dev/null +++ b/src/main/java/org/frankframework/flow/filesystem/FilesystemController.java @@ -0,0 +1,47 @@ +package org.frankframework.flow.filesystem; + +import java.io.IOException; +import java.nio.file.AccessDeniedException; +import java.util.List; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/filesystem") +public class FilesystemController { + + private final FileSystemStorage fileSystemStorage; + + public FilesystemController(FileSystemStorage fileSystemStorage) { + this.fileSystemStorage = fileSystemStorage; + } + + @GetMapping("/browse") + public ResponseEntity> browse(@RequestParam(required = false, defaultValue = "") String path) + throws IOException { + + List entries; + if (path.isBlank()) { + entries = fileSystemStorage.listRoots(); + } else { + try { + entries = fileSystemStorage.listDirectory(path); + } catch (AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + return ResponseEntity.ok(entries); + } + + @GetMapping("/default-path") + public ResponseEntity> defaultPath() { + String home = System.getProperty("user.home"); + return ResponseEntity.ok(Map.of("path", home)); + } +} diff --git a/src/main/java/org/frankframework/flow/filesystem/FilesystemEntry.java b/src/main/java/org/frankframework/flow/filesystem/FilesystemEntry.java new file mode 100644 index 00000000..95035641 --- /dev/null +++ b/src/main/java/org/frankframework/flow/filesystem/FilesystemEntry.java @@ -0,0 +1,3 @@ +package org.frankframework.flow.filesystem; + +public record FilesystemEntry(String name, String path, String type, boolean projectRoot) {} diff --git a/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java b/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java new file mode 100644 index 00000000..f540a496 --- /dev/null +++ b/src/main/java/org/frankframework/flow/filesystem/LocalFileSystemStorageService.java @@ -0,0 +1,96 @@ +package org.frankframework.flow.filesystem; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +@Service +@Profile("local") +public class LocalFileSystemStorageService implements FileSystemStorage { + @Override + public boolean isLocalEnvironment() { + return true; + } + + @Override + public List listRoots() { + List entries = new ArrayList<>(); + for (File root : File.listRoots()) { + String absolutePath = root.getAbsolutePath(); + entries.add(new FilesystemEntry(absolutePath, absolutePath, "DIRECTORY", false)); + } + return entries; + } + + @Override + public List listDirectory(String path) throws IOException { + Path dir = sanitizePath(path); + List entries = new ArrayList<>(); + + try (Stream stream = Files.list(dir)) { + stream.filter(Files::isDirectory).sorted().forEach(p -> { + boolean isProjectRoot = Files.isDirectory(p.resolve("src/main/configurations")); + entries.add(new FilesystemEntry( + p.getFileName().toString(), p.toAbsolutePath().toString(), "DIRECTORY", isProjectRoot)); + }); + } + return entries; + } + + @Override + public String readFile(String path) throws IOException { + return Files.readString(sanitizePath(path), StandardCharsets.UTF_8); + } + + @Override + public void writeFile(String path, String content) throws IOException { + Files.writeString(sanitizePath(path), content, StandardCharsets.UTF_8); + } + + @Override + public Path createProjectDirectory(String path) throws IOException { + Path dir = sanitizePath(path); + Files.createDirectories(dir); + return dir; + } + + @Override + public Path toAbsolutePath(String path) { + return sanitizePath(path); + } + + private static Path sanitizePath(String path) { + if (path == null || path.isBlank()) { + throw new SecurityException("Path must not be empty"); + } + + if (path.contains("..")) { + throw new SecurityException("Path traversal is not allowed: " + path); + } + + try { + Path candidate = Paths.get(path).toAbsolutePath().normalize(); + + for (File root : File.listRoots()) { + Path rootPath = root.toPath().toAbsolutePath().normalize(); + if (candidate.startsWith(rootPath)) { + Path relativePart = rootPath.relativize(candidate); + return rootPath.resolve(relativePart); + } + } + + throw new SecurityException("Access denied: " + path); + } catch (InvalidPathException e) { + throw new SecurityException("Invalid path: " + path, e); + } + } +} diff --git a/src/main/java/org/frankframework/flow/filesystem/WorkspaceCleanupService.java b/src/main/java/org/frankframework/flow/filesystem/WorkspaceCleanupService.java new file mode 100644 index 00000000..8f0b5056 --- /dev/null +++ b/src/main/java/org/frankframework/flow/filesystem/WorkspaceCleanupService.java @@ -0,0 +1,59 @@ +package org.frankframework.flow.filesystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.util.FileSystemUtils; + +@Slf4j +@Service +@Profile("cloud") +public class WorkspaceCleanupService { + private static final long CLEANUP_INTERVAL_MS = 3_600_000L; + + @Value("${frankflow.workspace.root:/tmp/frankflow/workspace}") + private String workspaceRootPath; + + @Value("${frankflow.workspace.retention-hours:24}") + private int retentionHours; + + @Scheduled(fixedRate = CLEANUP_INTERVAL_MS) + public void cleanupOldWorkspaces() { + log.info("Starting workspace cleanup check..."); + + Path root = Paths.get(workspaceRootPath); + if (!Files.exists(root)) { + log.info("Workspace root does not exist yet, skipping cleanup: {}", root); + return; + } + + Instant cutoffTime = Instant.now().minus(retentionHours, ChronoUnit.HOURS); + + try (Stream sessions = Files.list(root)) { + sessions.filter(Files::isDirectory).forEach(sessionDir -> { + try { + FileTime lastModifiedTime = Files.getLastModifiedTime(sessionDir); + + if (lastModifiedTime.toInstant().isBefore(cutoffTime)) { + log.info("Deleting expired workspace: {}", sessionDir); + FileSystemUtils.deleteRecursively(sessionDir); + } + } catch (IOException e) { + log.error("Failed to clean up session: {}", sessionDir, e); + } + }); + } catch (IOException e) { + log.error("Error accessing workspace root for cleanup", e); + } + } +} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java b/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java index 9906ff44..1063ae30 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeNode.java @@ -10,6 +10,7 @@ public class FileTreeNode { private String name; private String path; private NodeType type; + private boolean projectRoot; private List children; public FileTreeNode() {} diff --git a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java index 4eaa78ae..747d5825 100644 --- a/src/main/java/org/frankframework/flow/filetree/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/filetree/FileTreeService.java @@ -6,13 +6,16 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; import org.frankframework.flow.adapter.AdapterNotFoundException; import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; import org.frankframework.flow.utility.XmlAdapterUtils; import org.frankframework.flow.utility.XmlSecurityUtils; @@ -23,168 +26,187 @@ @Service public class FileTreeService { - private final Path projectsRoot; + private final ProjectService projectService; + private final FileSystemStorage fileSystemStorage; - public FileTreeService(ProjectService projectService) { - this.projectsRoot = projectService.getProjectsRoot(); - } - - public List listProjectFolders() throws IOException { - if (!Files.exists(projectsRoot) || !Files.isDirectory(projectsRoot)) { - throw new IllegalStateException("Projects root does not exist or is not a directory"); - } - - try (Stream paths = Files.list(projectsRoot)) { - return paths.filter(Files::isDirectory) - .map(path -> path.getFileName().toString()) - .sorted() - .collect(Collectors.toList()); - } - } + private final Map treeCache = new ConcurrentHashMap<>(); - public Path getProjectsRoot() { - if (!Files.exists(projectsRoot) || !Files.isDirectory(projectsRoot)) { - throw new IllegalStateException("Projects root does not exist or is not a directory"); - } - return projectsRoot; + public FileTreeService(ProjectService projectService, FileSystemStorage fileSystemStorage) { + this.projectService = projectService; + this.fileSystemStorage = fileSystemStorage; } - public String readFileContent(String absoluteFilepath) throws IOException { - Path filePath = Paths.get(absoluteFilepath).toAbsolutePath().normalize(); - - // Make sure file is under projects root - if (!filePath.startsWith(projectsRoot)) { - throw new IllegalArgumentException("File is outside of projects root: " + absoluteFilepath); - } + public String readFileContent(String filepath) throws IOException { + Path filePath = fileSystemStorage.toAbsolutePath(filepath); if (!Files.exists(filePath)) { - throw new NoSuchFileException("File does not exist: " + absoluteFilepath); + throw new NoSuchFileException("File does not exist: " + filepath); } if (Files.isDirectory(filePath)) { - throw new IllegalArgumentException("Requested path is a directory, not a file: " + absoluteFilepath); + throw new IllegalArgumentException("Requested path is a directory, not a file: " + filepath); } - return Files.readString(filePath, StandardCharsets.UTF_8); + return fileSystemStorage.readFile(filepath); } - public void updateFileContent(String absoluteFilepath, String newContent) throws IOException { - Path filePath = Paths.get(absoluteFilepath).toAbsolutePath().normalize(); - - // Make sure file is under projects root - if (!filePath.startsWith(projectsRoot)) { - throw new IllegalArgumentException("File is outside of projects root: " + absoluteFilepath); - } + public void updateFileContent(String filepath, String newContent) throws IOException { + Path filePath = fileSystemStorage.toAbsolutePath(filepath); if (!Files.exists(filePath)) { - throw new IllegalArgumentException("File does not exist: " + absoluteFilepath); + throw new IllegalArgumentException("File does not exist: " + filepath); } if (Files.isDirectory(filePath)) { - throw new IllegalArgumentException("Cannot update a directory: " + absoluteFilepath); + throw new IllegalArgumentException("Cannot update a directory: " + filepath); } - Files.writeString(filePath, newContent, StandardCharsets.UTF_8); + fileSystemStorage.writeFile(filepath, newContent); + invalidateTreeCache(); } public FileTreeNode getProjectTree(String projectName) throws IOException { - Path projectPath = projectsRoot.resolve(projectName).normalize(); + FileTreeNode cached = treeCache.get(projectName); + if (cached != null) { + return cached; + } + + try { + var project = projectService.getProject(projectName); + Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); + + if (!Files.exists(projectPath) || !Files.isDirectory(projectPath)) { + throw new IllegalArgumentException("Project directory does not exist: " + projectName); + } - if (!Files.exists(projectPath) || !Files.isDirectory(projectPath)) { + boolean useRelativePaths = !fileSystemStorage.isLocalEnvironment(); + Path relativizeRoot = useRelativePaths ? fileSystemStorage.toAbsolutePath("") : projectPath; + FileTreeNode tree = buildTree(projectPath, relativizeRoot, useRelativePaths); + tree.setProjectRoot(true); + treeCache.put(projectName, tree); + return tree; + } catch (ProjectNotFoundException e) { throw new IllegalArgumentException("Project does not exist: " + projectName); } - - return buildShallowTree(projectPath); } public FileTreeNode getShallowDirectoryTree(String projectName, String directoryPath) throws IOException { - Path dirPath = projectsRoot.resolve(projectName).resolve(directoryPath).normalize(); + try { + var project = projectService.getProject(projectName); + Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); + Path dirPath = projectPath.resolve(directoryPath).normalize(); - if (!dirPath.startsWith(projectsRoot.resolve(projectName))) { - throw new SecurityException("Invalid path: outside project directory"); - } + if (!dirPath.startsWith(projectPath)) { + throw new SecurityException("Invalid path: outside project directory"); + } - if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { - throw new IllegalArgumentException("Directory does not exist: " + dirPath); - } + if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { + throw new IllegalArgumentException("Directory does not exist: " + dirPath); + } - return buildShallowTree(dirPath); + return buildShallowTree(dirPath); + } catch (ProjectNotFoundException e) { + throw new IllegalArgumentException("Project does not exist: " + projectName); + } } public FileTreeNode getShallowConfigurationsDirectoryTree(String projectName) throws IOException { - Path configDirPath = projectsRoot - .resolve(projectName) - .resolve("src/main/configurations") - .normalize(); + try { + var project = projectService.getProject(projectName); + Path configDirPath = fileSystemStorage + .toAbsolutePath(project.getRootPath()) + .resolve("src/main/configurations") + .normalize(); + + if (!Files.exists(configDirPath) || !Files.isDirectory(configDirPath)) { + throw new IllegalArgumentException("Configurations directory does not exist: " + configDirPath); + } - if (!Files.exists(configDirPath) || !Files.isDirectory(configDirPath)) { - throw new IllegalArgumentException("Configurations directory does not exist: " + configDirPath); + return buildShallowTree(configDirPath); + } catch (ProjectNotFoundException e) { + throw new IllegalArgumentException("Configurations directory does not exist: " + projectName); } - - return buildShallowTree(configDirPath); } public FileTreeNode getConfigurationsDirectoryTree(String projectName) throws IOException { - Path configDirPath = projectsRoot - .resolve(projectName) - .resolve("src/main/configurations") - .normalize(); + try { + var project = projectService.getProject(projectName); + Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); + Path configDirPath = projectPath.resolve("src/main/configurations").normalize(); + + if (!Files.exists(configDirPath) || !Files.isDirectory(configDirPath)) { + throw new IllegalArgumentException("Configurations directory does not exist: " + configDirPath); + } - if (!Files.exists(configDirPath) || !Files.isDirectory(configDirPath)) { - throw new IllegalArgumentException("Configurations directory does not exist: " + configDirPath); + boolean useRelativePaths = !fileSystemStorage.isLocalEnvironment(); + Path relativizeRoot = useRelativePaths ? fileSystemStorage.toAbsolutePath("") : projectPath; + return buildTree(configDirPath, relativizeRoot, useRelativePaths); + } catch (ProjectNotFoundException e) { + throw new IllegalArgumentException("Configurations directory does not exist: " + projectName); } + } + + public void invalidateTreeCache() { + treeCache.clear(); + } - return buildTree(configDirPath); + public void invalidateTreeCache(String projectName) { + treeCache.remove(projectName); } public boolean updateAdapterFromFile( String projectName, Path configurationFile, String adapterName, String newAdapterXml) - throws ConfigurationNotFoundException, AdapterNotFoundException { + throws ConfigurationNotFoundException, AdapterNotFoundException, IOException { - if (!Files.exists(configurationFile)) { + Path absConfigFile = fileSystemStorage.toAbsolutePath(configurationFile.toString()); + + if (!Files.exists(absConfigFile)) { throw new ConfigurationNotFoundException("Configuration file not found: " + configurationFile); } try { - // Parse configuration XML from file Document configDoc = - XmlSecurityUtils.createSecureDocumentBuilder().parse(Files.newInputStream(configurationFile)); + XmlSecurityUtils.createSecureDocumentBuilder().parse(Files.newInputStream(absConfigFile)); - // Parse new adapter XML Document newAdapterDoc = XmlSecurityUtils.createSecureDocumentBuilder() .parse(new ByteArrayInputStream(newAdapterXml.getBytes(StandardCharsets.UTF_8))); Node newAdapterNode = newAdapterDoc.getDocumentElement(); - // Delegate replacement logic boolean replaced = XmlAdapterUtils.replaceAdapterInDocument(configDoc, adapterName, newAdapterNode); if (!replaced) { throw new AdapterNotFoundException("Adapter not found: " + adapterName); } - // Delegate document to string conversion String updatedXml = XmlAdapterUtils.convertDocumentToString(configDoc); - // Write updated XML back to file - Files.writeString( - configurationFile, updatedXml, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); + Files.writeString(absConfigFile, updatedXml, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); + invalidateTreeCache(projectName); return true; } catch (AdapterNotFoundException | ConfigurationNotFoundException e) { - throw e; // let GlobalExceptionHandler deal with it + throw e; } catch (Exception e) { System.err.println("Error updating adapter in file: " + e.getMessage()); return false; } } - // Recursive method to build the entire file tree - private FileTreeNode buildTree(Path path) throws IOException { + private FileTreeNode buildTree(Path path, Path relativizeRoot, boolean useRelativePaths) throws IOException { FileTreeNode node = new FileTreeNode(); node.setName(path.getFileName().toString()); - node.setPath(path.toAbsolutePath().toString()); + + if (useRelativePaths) { + String relativePath = relativizeRoot.relativize(path).toString().replace("\\", "/"); + if (relativePath.isEmpty()) { + relativePath = "."; + } + node.setPath(relativePath); + } else { + node.setPath(path.toAbsolutePath().toString()); + } if (Files.isDirectory(path)) { node.setType(NodeType.DIRECTORY); @@ -192,7 +214,7 @@ private FileTreeNode buildTree(Path path) throws IOException { try (Stream stream = Files.list(path)) { List children = stream.map(p -> { try { - return buildTree(p); + return buildTree(p, relativizeRoot, useRelativePaths); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/src/main/java/org/frankframework/flow/project/ProjectCloneDTO.java b/src/main/java/org/frankframework/flow/project/ProjectCloneDTO.java new file mode 100644 index 00000000..e6ed0a66 --- /dev/null +++ b/src/main/java/org/frankframework/flow/project/ProjectCloneDTO.java @@ -0,0 +1,3 @@ +package org.frankframework.flow.project; + +public record ProjectCloneDTO(String repoUrl, String localPath) {} diff --git a/src/main/java/org/frankframework/flow/project/ProjectController.java b/src/main/java/org/frankframework/flow/project/ProjectController.java index 756a39ec..3357b3ef 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectController.java +++ b/src/main/java/org/frankframework/flow/project/ProjectController.java @@ -1,23 +1,23 @@ package org.frankframework.flow.project; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; import java.util.List; -import java.util.Map; import org.frankframework.flow.adapter.AdapterNotFoundException; import org.frankframework.flow.adapter.AdapterUpdateDTO; import org.frankframework.flow.configuration.Configuration; import org.frankframework.flow.configuration.ConfigurationDTO; import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.filetree.FileTreeNode; import org.frankframework.flow.filetree.FileTreeService; -import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; +import org.frankframework.flow.recentproject.RecentProjectsService; import org.frankframework.flow.utility.XmlValidator; -import org.springframework.http.HttpStatus; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -28,44 +28,33 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; -@RestController() +@RestController @RequestMapping("/projects") public class ProjectController { + private final ProjectService projectService; private final FileTreeService fileTreeService; - - public ProjectController(ProjectService projectService, FileTreeService fileTreeService) { + private final RecentProjectsService recentProjectsService; + private final FileSystemStorage fileSystemStorage; + + public ProjectController( + ProjectService projectService, + FileTreeService fileTreeService, + RecentProjectsService recentProjectsService, + FileSystemStorage fileSystemStorage) { this.projectService = projectService; this.fileTreeService = fileTreeService; + this.recentProjectsService = recentProjectsService; + this.fileSystemStorage = fileSystemStorage; } @GetMapping public ResponseEntity> getAllProjects() { - List projectDTOList = new ArrayList<>(); List projects = projectService.getProjects(); - - for (Project project : projects) { - ProjectDTO dto = ProjectDTO.from(project); - projectDTOList.add(dto); - } - return ResponseEntity.ok(projectDTOList); - } - - @GetMapping("/backend-folders") - public List getBackendFolders() throws IOException { - return fileTreeService.listProjectFolders(); - } - - @GetMapping("/root") - public ResponseEntity> getProjectsRoot() { - return ResponseEntity.ok( - Map.of("rootPath", fileTreeService.getProjectsRoot().toString())); - } - - @GetMapping("/{name}/tree") - public FileTreeNode getProjectTree(@PathVariable String name) throws IOException { - return fileTreeService.getProjectTree(name); + List dtos = projects.stream().map(this::toDto).toList(); + return ResponseEntity.ok(dtos); } @GetMapping("/{name}/tree/configurations") @@ -82,12 +71,8 @@ public FileTreeNode getConfigurationTree( @GetMapping("/{projectName}") public ResponseEntity getProject(@PathVariable String projectName) throws ProjectNotFoundException { - Project project = projectService.getProject(projectName); - - ProjectDTO dto = ProjectDTO.from(project); - - return ResponseEntity.ok(dto); + return ResponseEntity.ok(toDto(project)); } @GetMapping(value = "/{projectname}", params = "path") @@ -97,163 +82,131 @@ public FileTreeNode getDirectoryContent(@PathVariable String projectname, @Reque return fileTreeService.getShallowDirectoryTree(projectname, path); } - @PatchMapping("/{projectname}") - public ResponseEntity patchProject( - @PathVariable String projectname, @RequestBody ProjectDTO projectDTO) { - - try { - Project project = projectService.getProject(projectname); - if (project == null) { - return ResponseEntity.notFound().build(); - } - - // 1. Update project name (only if present) - if (projectDTO.name() != null && !projectDTO.name().equals(project.getName())) { - project.setName(projectDTO.name()); - } - - // 2. Update configuration list (only if present) - if (projectDTO.filepaths() != null) { - // Replace entire configuration list - project.clearConfigurations(); - for (String filepath : projectDTO.filepaths()) { - project.addConfiguration(new Configuration(filepath)); - } - } + @PostMapping + public ResponseEntity createProject(@RequestBody ProjectCreateDTO projectCreateDTO) throws IOException { + Project project = projectService.createProjectOnDisk(projectCreateDTO.rootPath()); + recentProjectsService.addRecentProject(project.getName(), project.getRootPath()); + return ResponseEntity.ok(toDto(project)); + } - // 3. Merge filter map (only update provided filters) - if (projectDTO.filters() != null) { - for (var entry : projectDTO.filters().entrySet()) { - FilterType type = entry.getKey(); - Boolean enabled = entry.getValue(); + @PostMapping("/clone") + public ResponseEntity cloneProject(@RequestBody ProjectCloneDTO projectCloneDTO) throws IOException { + Project project = projectService.cloneAndOpenProject(projectCloneDTO.repoUrl(), projectCloneDTO.localPath()); + recentProjectsService.addRecentProject(project.getName(), project.getRootPath()); + return ResponseEntity.ok(toDto(project)); + } - if (enabled == null) continue; + @PostMapping("/open") + public ResponseEntity openProject(@RequestBody ProjectCreateDTO projectCreateDTO) throws IOException { + Project project = projectService.openProjectFromDisk(projectCreateDTO.rootPath()); + recentProjectsService.addRecentProject(project.getName(), project.getRootPath()); + return ResponseEntity.ok(toDto(project)); + } - if (enabled) { - project.enableFilter(type); - } else { - project.disableFilter(type); - } - } - } + @PostMapping("/{projectname}/configurations/{configname}") + public ResponseEntity addConfiguration( + @PathVariable String projectname, @PathVariable String configname) throws ProjectNotFoundException { + Project project = projectService.addConfiguration(projectname, configname); + return ResponseEntity.ok(toDto(project)); + } - ProjectDTO dto = ProjectDTO.from(project); + @PatchMapping("/{projectname}/filters/{type}/enable") + public ResponseEntity enableFilter(@PathVariable String projectname, @PathVariable String type) + throws ProjectNotFoundException, InvalidFilterTypeException { + Project project = projectService.enableFilter(projectname, type); + return ResponseEntity.ok(toDto(project)); + } - return ResponseEntity.ok(dto); + @PatchMapping("/{projectname}/filters/{type}/disable") + public ResponseEntity disableFilter(@PathVariable String projectname, @PathVariable String type) + throws ProjectNotFoundException, InvalidFilterTypeException { + Project project = projectService.disableFilter(projectname, type); + return ResponseEntity.ok(toDto(project)); + } - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } + @GetMapping("/{name}/tree") + public FileTreeNode getProjectTree(@PathVariable String name) throws IOException { + return fileTreeService.getProjectTree(name); } @PostMapping("/{projectName}/configuration") public ResponseEntity getConfigurationByPath( @PathVariable String projectName, @RequestBody ConfigurationPathDTO requestBody) - throws ProjectNotFoundException, ConfigurationNotFoundException, IOException { + throws ConfigurationNotFoundException, IOException { String filepath = requestBody.filepath(); - - // Find configuration by filepath - String content; try { - content = fileTreeService.readFileContent(filepath); + String content = fileTreeService.readFileContent(filepath); + return ResponseEntity.ok(new ConfigurationDTO(filepath, content)); } catch (NoSuchFileException e) { throw new ConfigurationNotFoundException("Configuration file not found: " + filepath); } catch (IllegalArgumentException e) { throw new ConfigurationNotFoundException("Invalid configuration path: " + filepath); } - - ConfigurationDTO dto = new ConfigurationDTO(filepath, content); - return ResponseEntity.ok(dto); - } - - @PostMapping("/{projectname}/import-configurations") - public ResponseEntity importConfigurations( - @PathVariable String projectname, @RequestBody ProjectImportDTO importDTO) throws ProjectNotFoundException { - - Project project = projectService.getProject(projectname); - if (project == null) return ResponseEntity.notFound().build(); - - for (ImportConfigurationDTO conf : importDTO.configurations()) { - Configuration c = new Configuration(conf.filepath()); - c.setXmlContent(conf.xmlContent()); - project.addConfiguration(c); - } - - ProjectDTO dto = ProjectDTO.from(project); - - return ResponseEntity.ok(dto); } @PutMapping("/{projectName}/configuration") public ResponseEntity updateConfiguration( @PathVariable String projectName, @RequestBody ConfigurationDTO configurationDTO) - throws ProjectNotFoundException, ConfigurationNotFoundException, InvalidXmlContentException, IOException { + throws ConfigurationNotFoundException, InvalidXmlContentException, IOException { - // Validate XML if (configurationDTO.filepath().toLowerCase().endsWith(".xml")) { XmlValidator.validateXml(configurationDTO.content()); } - try { fileTreeService.updateFileContent(configurationDTO.filepath(), configurationDTO.content()); + return ResponseEntity.ok().build(); } catch (IllegalArgumentException e) { throw new ConfigurationNotFoundException("Invalid file path: " + configurationDTO.filepath()); } - - return ResponseEntity.ok().build(); } @PutMapping("/{projectName}/adapters") public ResponseEntity updateAdapterFromFile( @PathVariable String projectName, @RequestBody AdapterUpdateDTO dto) - throws AdapterNotFoundException, ConfigurationNotFoundException { + throws AdapterNotFoundException, ConfigurationNotFoundException, IOException { Path configPath = Paths.get(dto.configurationPath()); - boolean updated = fileTreeService.updateAdapterFromFile(projectName, configPath, dto.adapterName(), dto.adapterXml()); - - if (!updated) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok().build(); + return updated ? ResponseEntity.ok().build() : ResponseEntity.notFound().build(); } - @PostMapping - public ResponseEntity createProject(@RequestBody ProjectCreateDTO projectCreateDTO) - throws ProjectAlreadyExistsException { - Project project = projectService.createProject(projectCreateDTO.name(), projectCreateDTO.rootPath()); - - ProjectDTO dto = ProjectDTO.from(project); + @GetMapping("/{projectName}/export") + public void exportProject(@PathVariable String projectName, HttpServletResponse response) + throws IOException, ProjectNotFoundException { + response.setContentType("application/zip"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + projectName + ".zip\""); - return ResponseEntity.ok(dto); + projectService.exportProjectAsZip(projectName, response.getOutputStream()); } - @PostMapping("/{projectname}/configurations/{configname}") - public ResponseEntity addConfiguration( - @PathVariable String projectname, @PathVariable String configname) throws ProjectNotFoundException { - Project project = projectService.addConfiguration(projectname, configname); - - ProjectDTO projectDTO = ProjectDTO.from(project); - return ResponseEntity.ok(projectDTO); - } + @PostMapping("/import") + public ResponseEntity importProject( + @RequestParam("files") List files, + @RequestParam("paths") List paths, + @RequestParam("projectName") String projectName) + throws IOException { - @PatchMapping("/{projectname}/filters/{type}/enable") - public ResponseEntity enableFilter(@PathVariable String projectname, @PathVariable String type) - throws ProjectNotFoundException, InvalidFilterTypeException { + if (files.isEmpty() || files.size() != paths.size()) { + return ResponseEntity.badRequest().build(); + } - Project project = projectService.enableFilter(projectname, type); - ProjectDTO dto = ProjectDTO.from(project); - return ResponseEntity.ok(dto); + Project project = projectService.importProjectFromFiles(projectName, files, paths); + recentProjectsService.addRecentProject(project.getName(), project.getRootPath()); + return ResponseEntity.ok(toDto(project)); } - @PatchMapping("/{projectname}/filters/{type}/disable") - public ResponseEntity disableFilter(@PathVariable String projectname, @PathVariable String type) - throws ProjectNotFoundException, InvalidFilterTypeException { + private ProjectDTO toDto(Project project) { + String cleanPath = fileSystemStorage.toRelativePath(project.getRootPath()); + List filepaths = project.getConfigurations().stream() + .map(Configuration::getFilepath) + .map(fileSystemStorage::toRelativePath) + .toList(); - Project project = projectService.disableFilter(projectname, type); - ProjectDTO dto = ProjectDTO.from(project); - return ResponseEntity.ok(dto); + return new ProjectDTO( + project.getName(), + cleanPath, + filepaths, + project.getProjectSettings().getFilters()); } } diff --git a/src/main/java/org/frankframework/flow/project/ProjectDTO.java b/src/main/java/org/frankframework/flow/project/ProjectDTO.java index 992c911d..7fbabea8 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectDTO.java +++ b/src/main/java/org/frankframework/flow/project/ProjectDTO.java @@ -1,23 +1,7 @@ package org.frankframework.flow.project; -import java.util.ArrayList; import java.util.List; import java.util.Map; -import org.frankframework.flow.configuration.Configuration; import org.frankframework.flow.projectsettings.FilterType; -public record ProjectDTO(String name, String rootPath, List filepaths, Map filters) { - - // Factory method to create a ProjectDTO from a Project - public static ProjectDTO from(Project project) { - List filepaths = new ArrayList<>(); - for (Configuration configuration : project.getConfigurations()) { - filepaths.add(configuration.getFilepath()); - } - return new ProjectDTO( - project.getName(), - project.getRootPath(), - filepaths, - project.getProjectSettings().getFilters()); - } -} +public record ProjectDTO(String name, String rootPath, List filepaths, Map filters) {} diff --git a/src/main/java/org/frankframework/flow/project/ProjectService.java b/src/main/java/org/frankframework/flow/project/ProjectService.java index b5532a98..fb2233a9 100644 --- a/src/main/java/org/frankframework/flow/project/ProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ProjectService.java @@ -1,156 +1,317 @@ package org.frankframework.flow.project; import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Optional; -import lombok.Getter; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; import org.frankframework.flow.adapter.AdapterNotFoundException; import org.frankframework.flow.configuration.Configuration; import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.frankframework.flow.filesystem.FilesystemEntry; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; +import org.frankframework.flow.recentproject.RecentProject; +import org.frankframework.flow.recentproject.RecentProjectsService; import org.frankframework.flow.utility.XmlAdapterUtils; import org.frankframework.flow.utility.XmlSecurityUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import org.w3c.dom.Document; import org.w3c.dom.Node; import org.xml.sax.SAXParseException; +@Slf4j @Service public class ProjectService { + private static final String CONFIGURATIONS_DIR = "src/main/configurations"; - @Getter - private final ArrayList projects = new ArrayList<>(); + private final FileSystemStorage fileSystemStorage; + private final RecentProjectsService recentProjectsService; - private static final String DEFAULT_PROJECT_ROOT = "src/main/resources/project"; - private static final int MIN_PARTS_LENGTH = 2; - private final ResourcePatternResolver resolver; - private final Path projectsRoot; + private final Map projectCache = new ConcurrentHashMap<>(); - @Autowired - public ProjectService(ResourcePatternResolver resolver, @Value("${app.project.root:}") String rootPath) { - this.resolver = resolver; - this.projectsRoot = resolveProjectRoot(rootPath); + public ProjectService(FileSystemStorage fileSystemStorage, @Lazy RecentProjectsService recentProjectsService) { + this.fileSystemStorage = fileSystemStorage; + this.recentProjectsService = recentProjectsService; } - private Path resolveProjectRoot(String rootPath) { - if (rootPath == null || rootPath.isBlank()) { - return Paths.get(DEFAULT_PROJECT_ROOT).toAbsolutePath().normalize(); + public List getProjects() { + if (fileSystemStorage.isLocalEnvironment()) { + return getProjectsFromRecentList(); } - return Paths.get(rootPath).toAbsolutePath().normalize(); + return getProjectsFromWorkspaceScan(); } - public Path getProjectsRoot() { - return projectsRoot; + private List getProjectsFromRecentList() { + List foundProjects = new ArrayList<>(); + List recentProjects = recentProjectsService.getRecentProjects(); + + for (RecentProject recent : recentProjects) { + try { + Project p = loadProjectCached(recent.rootPath()); + foundProjects.add(p); + } catch (Exception e) { + log.debug("Recent project no longer valid: {}", recent.rootPath()); + } + } + return foundProjects; } - public Project createProject(String name, String rootPath) throws ProjectAlreadyExistsException { - Project project = new Project(name, rootPath); - if (projects.contains(project)) { - throw new ProjectAlreadyExistsException( - "Project with name '" + name + "' and rootPath '" + rootPath + "' already exists."); + private List getProjectsFromWorkspaceScan() { + List foundProjects = new ArrayList<>(); + List entries = fileSystemStorage.listRoots(); + + for (FilesystemEntry entry : entries) { + try { + Project p = loadProjectCached(entry.path()); + foundProjects.add(p); + } catch (Exception e) { + // Not a valid project, skip + } } - projects.add(project); - return project; + return foundProjects; } public Project getProject(String name) throws ProjectNotFoundException { - for (Project project : projects) { - if (project.getName().equals(name)) { - return project; + for (Project cached : projectCache.values()) { + if (cached.getName().equals(name)) { + return cached; } } - throw new ProjectNotFoundException(String.format("Project with name: %s cannot be found", name)); + return getProjects().stream() + .filter(p -> p.getName().equals(name)) + .findFirst() + .orElseThrow(() -> new ProjectNotFoundException("Project not found: " + name)); } - public Project addConfigurations(String projectName, ArrayList configurationPaths) - throws ProjectNotFoundException { - Project project = getProject(projectName); + public Project createProjectOnDisk(String path) throws IOException { + Path projectPath = fileSystemStorage.createProjectDirectory(path); + + Files.createDirectories(projectPath.resolve(CONFIGURATIONS_DIR)); + + String defaultXml = new String( + new ClassPathResource("templates/default-configuration.xml") + .getInputStream() + .readAllBytes(), + StandardCharsets.UTF_8); + fileSystemStorage.writeFile( + projectPath + .resolve(CONFIGURATIONS_DIR) + .resolve("Configuration.xml") + .toString(), + defaultXml); + + return loadProjectAndCache(projectPath.toString()); + } + + public Project openProjectFromDisk(String path) throws IOException { + return loadProjectAndCache(path); + } + + public Project cloneAndOpenProject(String repoUrl, String localPath) throws IOException { + Path targetDir = fileSystemStorage.toAbsolutePath(localPath); + + if (Files.exists(targetDir)) { + throw new IllegalArgumentException("Project already exists at: " + targetDir); + } + + try (Git git = Git.cloneRepository() + .setURI(repoUrl) + .setDirectory(targetDir.toFile()) + .call()) { + log.info("Cloned repository {} to {}", repoUrl, targetDir); + } catch (GitAPIException e) { + throw new IOException("git clone failed: " + e.getMessage(), e); + } - for (String path : configurationPaths) { - Configuration config = new Configuration(path); - project.addConfiguration(config); + return loadProjectAndCache(targetDir.toString()); + } + + public void invalidateCache() { + projectCache.clear(); + } + + public void invalidateProject(String projectName) { + projectCache.entrySet().removeIf(e -> e.getValue().getName().equals(projectName)); + } + + private Project loadProjectCached(String path) throws IOException { + String cacheKey = fileSystemStorage.toAbsolutePath(path).toString(); + Project cached = projectCache.get(cacheKey); + if (cached != null) { + return cached; } + return loadProjectAndCache(path); + } + private Project loadProjectAndCache(String path) throws IOException { + Project project = loadProjectFromStorage(path); + String cacheKey = fileSystemStorage.toAbsolutePath(path).toString(); + projectCache.put(cacheKey, project); return project; } - public boolean updateConfigurationXml(String projectName, String filepath, String xmlContent) - throws ProjectNotFoundException, ConfigurationNotFoundException { + private Project loadProjectFromStorage(String path) throws IOException { + Path absPath = fileSystemStorage.toAbsolutePath(path); + + validatePathSafety(absPath); + + if (!Files.exists(absPath) || !Files.isDirectory(absPath)) { + throw new IOException("Invalid project path: " + absPath); + } + + Project project = new Project(absPath.getFileName().toString(), absPath.toString()); + + Path configDir = absPath.resolve(CONFIGURATIONS_DIR).normalize(); + + validatePathSafety(configDir); + + if (!Files.exists(configDir)) { + return project; + } + + try (Stream s = Files.walk(configDir)) { + s.filter(p -> p.toString().endsWith(".xml")).forEach(p -> { + try { + String content = fileSystemStorage.readFile(p.toString()); + Configuration c = new Configuration(p.toString()); + c.setXmlContent(content); + project.addConfiguration(c); + } catch (IOException e) { + log.error("Error reading config file {}: {}", p, e.getMessage(), e); + } + }); + } + return project; + } + + private static void validatePathSafety(Path path) { + String pathStr = path.toString(); + if (pathStr.contains("..")) { + throw new SecurityException("Path traversal is not allowed: " + pathStr); + } + } + public void exportProjectAsZip(String projectName, OutputStream outputStream) + throws IOException, ProjectNotFoundException { Project project = getProject(projectName); + Path projectPath = fileSystemStorage.toAbsolutePath(project.getRootPath()); + + if (!Files.exists(projectPath) || !Files.isDirectory(projectPath)) { + throw new ProjectNotFoundException("Project directory not found: " + projectName); + } + + try (ZipOutputStream zos = new ZipOutputStream(outputStream); + Stream paths = Files.walk(projectPath)) { + paths.filter(Files::isRegularFile).forEach(filePath -> { + try { + String entryName = + projectPath.relativize(filePath).toString().replace("\\", "/"); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(filePath, zos); + zos.closeEntry(); + } catch (IOException e) { + throw new RuntimeException("Error zipping file: " + filePath, e); + } + }); + } + } + + public Project importProjectFromFiles(String projectName, List files, List paths) + throws IOException { + Path projectDir = fileSystemStorage.createProjectDirectory(projectName); + + for (int i = 0; i < files.size(); i++) { + String relativePath = paths.get(i).replace("\\", "/"); + + if (relativePath.contains("..") || relativePath.startsWith("/")) { + throw new SecurityException("Invalid file path: " + relativePath); + } - for (Configuration config : project.getConfigurations()) { - if (config.getFilepath().equals(filepath)) { - config.setXmlContent(xmlContent); - return true; + Path targetPath = projectDir.resolve(relativePath).normalize(); + if (!targetPath.startsWith(projectDir)) { + throw new SecurityException("File path escapes project directory: " + relativePath); } + + Files.createDirectories(targetPath.getParent()); + files.get(i).transferTo(targetPath); } - throw new ConfigurationNotFoundException( - String.format("Configuration with filepath: %s can not be found", filepath)); + return loadProjectAndCache(projectDir.toString()); } - public Project enableFilter(String projectName, String type) - throws ProjectNotFoundException, InvalidFilterTypeException { + public boolean updateConfigurationXml(String projectName, String filepath, String xmlContent) + throws ProjectNotFoundException, ConfigurationNotFoundException, IOException { Project project = getProject(projectName); - FilterType filterType; - try { - filterType = FilterType.valueOf(type.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new InvalidFilterTypeException("Invalid filter type: " + type); - } + Configuration targetConfig = project.getConfigurations().stream() + .filter(c -> c.getFilepath().equals(filepath)) + .findFirst() + .orElseThrow(() -> new ConfigurationNotFoundException( + String.format("Configuration with filepath: %s not found", filepath))); - project.enableFilter(filterType); + fileSystemStorage.writeFile(filepath, xmlContent); + targetConfig.setXmlContent(xmlContent); + return true; + } + + public Project enableFilter(String projectName, String type) + throws ProjectNotFoundException, InvalidFilterTypeException { + Project project = getProject(projectName); + project.enableFilter(parseFilterType(type)); return project; } public Project disableFilter(String projectName, String type) throws ProjectNotFoundException, InvalidFilterTypeException { - Project project = getProject(projectName); + project.disableFilter(parseFilterType(type)); + return project; + } - FilterType filterType; + private FilterType parseFilterType(String type) throws InvalidFilterTypeException { try { - filterType = FilterType.valueOf(type.toUpperCase()); + return FilterType.valueOf(type.toUpperCase()); } catch (IllegalArgumentException e) { throw new InvalidFilterTypeException("Invalid filter type: " + type); } - - project.disableFilter(filterType); - return project; } public boolean updateAdapter(String projectName, String configurationPath, String adapterName, String newAdapterXml) throws ProjectNotFoundException, ConfigurationNotFoundException, AdapterNotFoundException { Project project = getProject(projectName); - Optional configOptional = project.getConfigurations().stream() .filter(configuration -> configuration.getFilepath().equals(configurationPath)) .findFirst(); if (configOptional.isEmpty()) { - System.err.println("Configuration not found: " + configurationPath); throw new ConfigurationNotFoundException("Configuration not found: " + configurationPath); } Configuration config = configOptional.get(); try { - // Parse existing config Document configDoc = XmlSecurityUtils.createSecureDocumentBuilder() .parse(new ByteArrayInputStream(config.getXmlContent().getBytes(StandardCharsets.UTF_8))); - // Parse new adapter Document newAdapterDoc = XmlSecurityUtils.createSecureDocumentBuilder() .parse(new ByteArrayInputStream(newAdapterXml.getBytes(StandardCharsets.UTF_8))); @@ -162,24 +323,20 @@ public boolean updateAdapter(String projectName, String configurationPath, Strin String xmlOutput = XmlAdapterUtils.convertDocumentToString(configDoc); config.setXmlContent(xmlOutput); - return true; - } catch (AdapterNotFoundException | ConfigurationNotFoundException | ProjectNotFoundException e) { throw e; } catch (SAXParseException e) { - System.err.println("Invalid XML for adapter " + adapterName + ": " + e.getMessage()); + log.warn("Invalid XML for adapter {}: {}", adapterName, e.getMessage()); return false; } catch (Exception e) { - System.err.println("Unexpected error updating adapter: " + e.getMessage()); - e.printStackTrace(); + log.error("Unexpected error updating adapter: {}", e.getMessage(), e); return false; } } public Project addConfiguration(String projectName, String configurationName) throws ProjectNotFoundException { Project project = getProject(projectName); - Configuration configuration = new Configuration(configurationName); project.addConfiguration(configuration); return project; diff --git a/src/main/java/org/frankframework/flow/recentproject/RecentProject.java b/src/main/java/org/frankframework/flow/recentproject/RecentProject.java new file mode 100644 index 00000000..f80195ac --- /dev/null +++ b/src/main/java/org/frankframework/flow/recentproject/RecentProject.java @@ -0,0 +1,3 @@ +package org.frankframework.flow.recentproject; + +public record RecentProject(String name, String rootPath, String lastOpened) {} diff --git a/src/main/java/org/frankframework/flow/recentproject/RecentProjectController.java b/src/main/java/org/frankframework/flow/recentproject/RecentProjectController.java new file mode 100644 index 00000000..2ca63449 --- /dev/null +++ b/src/main/java/org/frankframework/flow/recentproject/RecentProjectController.java @@ -0,0 +1,56 @@ +package org.frankframework.flow.recentproject; + +import java.util.List; +import java.util.Map; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/recent-projects") +public class RecentProjectController { + private final RecentProjectsService recentProjectsService; + private final FileSystemStorage fileSystemStorage; + + public RecentProjectController(RecentProjectsService recentProjectsService, FileSystemStorage fileSystemStorage) { + this.recentProjectsService = recentProjectsService; + this.fileSystemStorage = fileSystemStorage; + } + + @GetMapping + public ResponseEntity> getRecentProjects() { + List projects = recentProjectsService.getRecentProjects(); + + if (!fileSystemStorage.isLocalEnvironment()) { + projects = projects.stream() + .map(p -> + new RecentProject(p.name(), fileSystemStorage.toRelativePath(p.rootPath()), p.lastOpened())) + .toList(); + } + + return ResponseEntity.ok(projects); + } + + @DeleteMapping + public ResponseEntity removeRecentProject(@RequestBody Map body) { + String rootPath = body.get("rootPath"); + if (rootPath == null || rootPath.isBlank()) { + return ResponseEntity.badRequest().build(); + } + + if (!fileSystemStorage.isLocalEnvironment()) { + try { + rootPath = fileSystemStorage.toAbsolutePath(rootPath).toString(); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } + } + + recentProjectsService.removeRecentProject(rootPath); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/org/frankframework/flow/recentproject/RecentProjectsService.java b/src/main/java/org/frankframework/flow/recentproject/RecentProjectsService.java new file mode 100644 index 00000000..baff0686 --- /dev/null +++ b/src/main/java/org/frankframework/flow/recentproject/RecentProjectsService.java @@ -0,0 +1,117 @@ +package org.frankframework.flow.recentproject; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import lombok.extern.slf4j.Slf4j; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class RecentProjectsService { + + private static final int MAX_RECENT_PROJECTS = 10; + private static final String RECENT_FILENAME = "recent-projects.json"; + + private static final Path LOCAL_USER_FILE = Paths.get(System.getProperty("user.home"), ".flow", RECENT_FILENAME); + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + private final ObjectMapper objectMapper; + private final FileSystemStorage fileSystemStorage; + + public RecentProjectsService(FileSystemStorage fileSystemStorage, ObjectMapper objectMapper) { + this.fileSystemStorage = fileSystemStorage; + this.objectMapper = objectMapper; + } + + public List getRecentProjects() { + lock.readLock().lock(); + try { + if (fileSystemStorage.isLocalEnvironment()) { + if (!Files.exists(LOCAL_USER_FILE)) return new ArrayList<>(); + return objectMapper.readValue(Files.readString(LOCAL_USER_FILE), new TypeReference<>() {}); + } + + try { + String json = fileSystemStorage.readFile(RECENT_FILENAME); + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } catch (IOException e) { + log.warn("Error reading recent projects: {}", e.getMessage()); + return new ArrayList<>(); + } finally { + lock.readLock().unlock(); + } + } + + public void addRecentProject(String name, String rootPath) { + if (name == null || name.isBlank() || rootPath == null || rootPath.isBlank()) return; + + String normalizedPath = Paths.get(rootPath).normalize().toString(); + + lock.writeLock().lock(); + try { + List projects = new ArrayList<>(getRecentProjects()); + + projects.removeIf(p -> { + String pPath = Paths.get(p.rootPath()).normalize().toString(); + return pPath.equals(normalizedPath); + }); + + projects.addFirst( + new RecentProject(name, normalizedPath, Instant.now().toString())); + + if (projects.size() > MAX_RECENT_PROJECTS) { + projects = new ArrayList<>(projects.subList(0, MAX_RECENT_PROJECTS)); + } + + saveProjects(projects); + } finally { + lock.writeLock().unlock(); + } + } + + public void removeRecentProject(String rootPath) { + if (rootPath == null || rootPath.isBlank()) return; + + String normalizedPath = Paths.get(rootPath).normalize().toString(); + + lock.writeLock().lock(); + try { + List projects = new ArrayList<>(getRecentProjects()); + projects.removeIf(p -> { + String pPath = Paths.get(p.rootPath()).normalize().toString(); + return pPath.equals(normalizedPath); + }); + saveProjects(projects); + } finally { + lock.writeLock().unlock(); + } + } + + private void saveProjects(List projects) { + try { + String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(projects); + + if (fileSystemStorage.isLocalEnvironment()) { + Files.createDirectories(LOCAL_USER_FILE.getParent()); + Files.writeString(LOCAL_USER_FILE, json); + } else { + fileSystemStorage.writeFile(RECENT_FILENAME, json); + } + } catch (IOException e) { + log.error("Error saving recent projects: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/org/frankframework/flow/security/UserContextFilter.java b/src/main/java/org/frankframework/flow/security/UserContextFilter.java new file mode 100644 index 00000000..f429ec6c --- /dev/null +++ b/src/main/java/org/frankframework/flow/security/UserContextFilter.java @@ -0,0 +1,95 @@ +package org.frankframework.flow.security; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HexFormat; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class UserContextFilter extends HttpFilter { + + private static final String BEARER_PREFIX = "Bearer "; + private static final int SESSION_HASH_LENGTH = 16; + private static final int MIN_JWT_PARTS = 2; + + private final UserWorkspaceContext userWorkspaceContext; + private final ObjectMapper objectMapper; + + public UserContextFilter(UserWorkspaceContext userWorkspaceContext, ObjectMapper objectMapper) { + this.userWorkspaceContext = userWorkspaceContext; + this.objectMapper = objectMapper; + } + + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (!userWorkspaceContext.isInitialized()) { + String workspaceId = determineWorkspaceId(request); + userWorkspaceContext.initialize(workspaceId); + log.debug("Context initialized for workspace: {}", workspaceId); + } + + chain.doFilter(request, response); + } + + private String determineWorkspaceId(HttpServletRequest request) { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + String userId = extractUserIdFromJwt(authHeader.substring(BEARER_PREFIX.length())); + if (userId != null) return sanitize(userId); + } + + String sessionId = request.getHeader("X-Session-ID"); + if (sessionId != null && !sessionId.isBlank()) { + return "anon-" + hashSessionId(sessionId); + } + + return "anonymous"; + } + + private String hashSessionId(String sessionId) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(sessionId.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash).substring(0, SESSION_HASH_LENGTH); + } catch (NoSuchAlgorithmException e) { + return sanitize(sessionId); + } + } + + private String sanitize(String input) { + return input.replaceAll("[^a-zA-Z0-9.@-]", "_"); + } + + private String extractUserIdFromJwt(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length < MIN_JWT_PARTS) return null; + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + JsonNode claims = objectMapper.readTree(payload); + + if (!claims.path("sub").isMissingNode()) { + return claims.path("sub").asText(); + } + if (!claims.path("preferred_username").isMissingNode()) { + return claims.path("preferred_username").asText(); + } + } catch (Exception e) { + log.warn("Invalid JWT format"); + } + return null; + } +} diff --git a/src/main/java/org/frankframework/flow/security/UserWorkspaceContext.java b/src/main/java/org/frankframework/flow/security/UserWorkspaceContext.java new file mode 100644 index 00000000..cb2ebaa3 --- /dev/null +++ b/src/main/java/org/frankframework/flow/security/UserWorkspaceContext.java @@ -0,0 +1,22 @@ +package org.frankframework.flow.security; + +import java.io.Serializable; +import lombok.Getter; +import lombok.Setter; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +@Component +@RequestScope +@Getter +@Setter +public class UserWorkspaceContext implements Serializable { + + private String workspaceId; + private boolean initialized = false; + + public void initialize(String id) { + this.workspaceId = id; + this.initialized = true; + } +} diff --git a/src/main/java/org/frankframework/flow/utility/XmlSecurityUtils.java b/src/main/java/org/frankframework/flow/utility/XmlSecurityUtils.java index dc1462fe..303d9f49 100644 --- a/src/main/java/org/frankframework/flow/utility/XmlSecurityUtils.java +++ b/src/main/java/org/frankframework/flow/utility/XmlSecurityUtils.java @@ -6,10 +6,6 @@ import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerFactory; import lombok.experimental.UtilityClass; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; /** * Utility class for creating secure XML parsers and transformers that prevent XXE vulnerabilities. @@ -67,27 +63,4 @@ public static TransformerFactory createSecureTransformerFactory() { } return factory; } - - /** - * Replaces an element in a document by tag name and attribute value. - * - * @param document The document to search in - * @param tagName The tag name to search for (e.g., "Adapter") - * @param attributeName The attribute name to match (e.g., "name") - * @param attributeValue The attribute value to match - * @param replacementNode The node to replace with - * @return true if the element was found and replaced, false otherwise - */ - public static boolean replaceElementByAttribute( - Document document, String tagName, String attributeName, String attributeValue, Node replacementNode) { - NodeList elements = document.getElementsByTagName(tagName); - for (int i = 0; i < elements.getLength(); i++) { - Element element = (Element) elements.item(i); - if (attributeValue.equals(element.getAttribute(attributeName))) { - element.getParentNode().replaceChild(replacementNode, element); - return true; - } - } - return false; - } } diff --git a/src/main/java/org/frankframework/flow/utility/XmlValidator.java b/src/main/java/org/frankframework/flow/utility/XmlValidator.java index 1a8e7356..cf9ada45 100644 --- a/src/main/java/org/frankframework/flow/utility/XmlValidator.java +++ b/src/main/java/org/frankframework/flow/utility/XmlValidator.java @@ -5,14 +5,14 @@ import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; +import lombok.experimental.UtilityClass; import org.frankframework.flow.project.InvalidXmlContentException; import org.xml.sax.InputSource; import org.xml.sax.SAXException; +@UtilityClass public class XmlValidator { - private XmlValidator() {} - public static void validateXml(String xmlContent) throws InvalidXmlContentException { if (xmlContent == null || xmlContent.isBlank()) { return; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 26a897ec..a9a26def 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,7 @@ spring.application.name=Flow cors.allowed.origins=* -# default is resources folder in this project -# change this to your project path if needed -app.project.root= - spring.web.resources.static-locations=classpath:/frontend/ + +spring.servlet.multipart.max-file-size=50MB +spring.servlet.multipart.max-request-size=50MB diff --git a/src/main/resources/templates/default-configuration.xml b/src/main/resources/templates/default-configuration.xml new file mode 100644 index 00000000..2dc25387 --- /dev/null +++ b/src/main/resources/templates/default-configuration.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/test/java/org/frankframework/flow/FlowApplicationTests.java b/src/test/java/org/frankframework/flow/FlowApplicationTests.java index 1287039b..1537bfd8 100644 --- a/src/test/java/org/frankframework/flow/FlowApplicationTests.java +++ b/src/test/java/org/frankframework/flow/FlowApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; -@SpringBootTest(properties = {"app.project.root=target/test-projects"}) +@SpringBootTest +@ActiveProfiles("local") class FlowApplicationTests { @Test diff --git a/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java b/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java index 041fac55..cf724004 100644 --- a/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java +++ b/src/test/java/org/frankframework/flow/cypress/RunCypressE2eTest.java @@ -16,7 +16,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; -import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.stream.Stream; @@ -60,7 +59,6 @@ static void setUp() { private static void startApplication() { SpringApplication springApplication = FlowApplication.configureApplication(); - springApplication.setDefaultProperties(Map.of("app.project.root", "/tmp/flow-projects")); run = springApplication.run(); assertTrue(run.isRunning()); diff --git a/src/test/java/org/frankframework/flow/filesystem/CloudFileSystemStorageServiceTest.java b/src/test/java/org/frankframework/flow/filesystem/CloudFileSystemStorageServiceTest.java new file mode 100644 index 00000000..eaef89b8 --- /dev/null +++ b/src/test/java/org/frankframework/flow/filesystem/CloudFileSystemStorageServiceTest.java @@ -0,0 +1,191 @@ +package org.frankframework.flow.filesystem; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import org.frankframework.flow.security.UserWorkspaceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class CloudFileSystemStorageServiceTest { + + private CloudFileSystemStorageService service; + private UserWorkspaceContext userContext; + private Path tempWorkspaceRoot; + + @BeforeEach + void setUp() throws IOException { + tempWorkspaceRoot = Files.createTempDirectory("cloud_test_workspace"); + userContext = new UserWorkspaceContext(); + userContext.initialize("test-user"); + + service = new CloudFileSystemStorageService(userContext); + ReflectionTestUtils.setField(service, "baseWorkspacePath", tempWorkspaceRoot.toString()); + } + + @AfterEach + void tearDown() throws IOException { + if (tempWorkspaceRoot != null && Files.exists(tempWorkspaceRoot)) { + try (var stream = Files.walk(tempWorkspaceRoot)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException ignored) { + } + }); + } + } + } + + @Test + void isLocalEnvironmentReturnsFalse() { + assertFalse(service.isLocalEnvironment()); + } + + @Test + void toAbsolutePathResolvesRelativeToUserRoot() throws IOException { + Path result = service.toAbsolutePath("my-project"); + Path expectedUserRoot = + tempWorkspaceRoot.resolve("test-user").toAbsolutePath().normalize(); + assertEquals(expectedUserRoot.resolve("my-project"), result); + } + + @Test + void toAbsolutePathReturnsUserRootForEmptyPath() throws IOException { + Path result = service.toAbsolutePath(""); + Path expectedUserRoot = + tempWorkspaceRoot.resolve("test-user").toAbsolutePath().normalize(); + assertEquals(expectedUserRoot, result); + } + + @Test + void toAbsolutePathReturnsUserRootForSlash() throws IOException { + Path result = service.toAbsolutePath("/"); + Path expectedUserRoot = + tempWorkspaceRoot.resolve("test-user").toAbsolutePath().normalize(); + assertEquals(expectedUserRoot, result); + } + + @Test + void toAbsolutePathRejectsPathTraversal() { + assertThrows(SecurityException.class, () -> service.toAbsolutePath("../other-user/secret")); + } + + @Test + void writeAndReadFile() throws IOException { + String content = "hello world"; + service.writeFile("test.txt", content); + + String result = service.readFile("test.txt"); + assertEquals(content, result); + } + + @Test + void createProjectDirectoryCreatesDir() throws IOException { + Path projectDir = service.createProjectDirectory("new-project"); + + assertTrue(Files.exists(projectDir)); + assertTrue(Files.isDirectory(projectDir)); + } + + @Test + void listDirectoryReturnsSubdirectories() throws IOException { + Path userRoot = tempWorkspaceRoot.resolve("test-user"); + Files.createDirectories(userRoot.resolve("projectA/src/main/configurations")); + Files.createDirectories(userRoot.resolve("projectB")); + + List entries = service.listDirectory(""); + + assertEquals(2, entries.size()); + assertTrue(entries.stream().anyMatch(e -> e.name().equals("projectA") && e.projectRoot())); + assertTrue(entries.stream().anyMatch(e -> e.name().equals("projectB") && !e.projectRoot())); + } + + @Test + void listRootsReturnsWorkspaceContents() throws IOException { + Path userRoot = tempWorkspaceRoot.resolve("test-user"); + Files.createDirectories(userRoot.resolve("projectA")); + + List entries = service.listRoots(); + + assertFalse(entries.isEmpty()); + assertTrue(entries.stream().anyMatch(e -> e.name().equals("projectA"))); + } + + @Test + void listRootsReturnsEmptyListOnError() { + ReflectionTestUtils.setField(service, "baseWorkspacePath", "/nonexistent/path/that/doesnt/exist"); + userContext.initialize("no-such-user-xyz"); + + List entries = service.listRoots(); + assertNotNull(entries); + } + + @Test + void readFileRejectsPathTraversal() { + assertThrows(SecurityException.class, () -> service.readFile("../../etc/passwd")); + } + + @Test + void writeFileRejectsPathTraversal() { + assertThrows(SecurityException.class, () -> service.writeFile("../../etc/evil", "data")); + } + + @Test + void toRelativePathStripsUserRoot() throws IOException { + Path userRoot = tempWorkspaceRoot.resolve("test-user").toAbsolutePath().normalize(); + String absolutePath = userRoot.resolve("project/file.xml").toString(); + + String relative = service.toRelativePath(absolutePath); + + assertTrue(relative.startsWith("/")); + assertTrue(relative.contains("project")); + assertTrue(relative.contains("file.xml")); + } + + @Test + void toRelativePathReturnsSlashForUserRoot() throws IOException { + Path userRoot = tempWorkspaceRoot.resolve("test-user").toAbsolutePath().normalize(); + + String relative = service.toRelativePath(userRoot.toString()); + + assertEquals("/", relative); + } + + @Test + void toRelativePathReturnsInputIfNotUnderUserRoot() throws IOException { + String result = service.toRelativePath("/some/other/path"); + assertEquals("/some/other/path", result); + } + + @Test + void anonymousUserWhenWorkspaceIdIsNull() throws IOException { + UserWorkspaceContext anonCtx = new UserWorkspaceContext(); + CloudFileSystemStorageService anonService = new CloudFileSystemStorageService(anonCtx); + ReflectionTestUtils.setField(anonService, "baseWorkspacePath", tempWorkspaceRoot.toString()); + + Path result = anonService.toAbsolutePath("test"); + assertTrue(result.toString().contains("anonymous")); + } + + @Test + void createProjectDirectoryRejectsPathTraversal() { + assertThrows(SecurityException.class, () -> service.createProjectDirectory("../../escape")); + } + + @Test + void writeFileCreatesContent() throws IOException { + service.writeFile("data.txt", "content"); + + Path userRoot = tempWorkspaceRoot.resolve("test-user").toAbsolutePath().normalize(); + String onDisk = Files.readString(userRoot.resolve("data.txt"), StandardCharsets.UTF_8); + assertEquals("content", onDisk); + } +} diff --git a/src/test/java/org/frankframework/flow/filesystem/LocalFileSystemStorageServiceTest.java b/src/test/java/org/frankframework/flow/filesystem/LocalFileSystemStorageServiceTest.java new file mode 100644 index 00000000..173c592b --- /dev/null +++ b/src/test/java/org/frankframework/flow/filesystem/LocalFileSystemStorageServiceTest.java @@ -0,0 +1,141 @@ +package org.frankframework.flow.filesystem; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class LocalFileSystemStorageServiceTest { + + private LocalFileSystemStorageService service; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + service = new LocalFileSystemStorageService(); + } + + @Test + void isLocalEnvironmentReturnsTrue() { + assertTrue(service.isLocalEnvironment()); + } + + @Test + void listRootsReturnsNonEmptyList() { + List roots = service.listRoots(); + assertNotNull(roots); + assertFalse(roots.isEmpty()); + roots.forEach(r -> assertEquals("DIRECTORY", r.type())); + } + + @Test + void listDirectoryReturnsSubdirectories() throws IOException { + Path subDir = Files.createDirectory(tempDir.resolve("subdir")); + Files.createDirectories(subDir.resolve("src/main/configurations")); + Files.createDirectory(tempDir.resolve("other")); + Files.writeString(tempDir.resolve("file.txt"), "data"); + + List entries = service.listDirectory(tempDir.toString()); + + assertEquals(2, entries.size()); + assertTrue(entries.stream().anyMatch(e -> e.name().equals("subdir") && e.projectRoot())); + assertTrue(entries.stream().anyMatch(e -> e.name().equals("other") && !e.projectRoot())); + } + + @Test + void listDirectoryExcludesFiles() throws IOException { + Files.writeString(tempDir.resolve("file.txt"), "data"); + + List entries = service.listDirectory(tempDir.toString()); + + assertTrue(entries.isEmpty()); + } + + @Test + void writeAndReadFile() throws IOException { + Path file = tempDir.resolve("test.txt"); + service.writeFile(file.toString(), "hello"); + + String content = service.readFile(file.toString()); + assertEquals("hello", content); + } + + @Test + void readFileThrowsForMissingFile() { + String missingPath = tempDir.resolve("nonexistent.txt").toString(); + assertThrows(IOException.class, () -> service.readFile(missingPath)); + } + + @Test + void createProjectDirectoryCreatesDirectory() throws IOException { + Path projectDir = tempDir.resolve("new-project"); + Path result = service.createProjectDirectory(projectDir.toString()); + + assertTrue(Files.exists(result)); + assertTrue(Files.isDirectory(result)); + assertEquals( + projectDir.toAbsolutePath().normalize(), result.toAbsolutePath().normalize()); + } + + @Test + void toAbsolutePathNormalizesCleanPath() { + Path result = service.toAbsolutePath(tempDir.resolve("b").toString()); + assertFalse(result.toString().contains("..")); + assertTrue(result.isAbsolute()); + } + + @Test + void sanitizePathRejectsPathTraversal() { + assertThrows(SecurityException.class, () -> service.listDirectory("../../../etc")); + } + + @Test + void sanitizePathRejectsEmptyPath() { + assertThrows(SecurityException.class, () -> service.readFile("")); + } + + @Test + void sanitizePathRejectsBlankPath() { + assertThrows(SecurityException.class, () -> service.readFile(" ")); + } + + @Test + void writeFileRejectsPathTraversal() { + assertThrows(SecurityException.class, () -> service.writeFile("../../evil.txt", "bad")); + } + + @Test + void createProjectDirectoryRejectsPathTraversal() { + assertThrows(SecurityException.class, () -> service.createProjectDirectory("../../escape")); + } + + @Test + void toAbsolutePathRejectsPathTraversal() { + assertThrows(SecurityException.class, () -> service.toAbsolutePath("../../etc/passwd")); + } + + @Test + void writeFileOverwritesExistingContent() throws IOException { + Path file = tempDir.resolve("overwrite.txt"); + service.writeFile(file.toString(), "first"); + service.writeFile(file.toString(), "second"); + + assertEquals("second", service.readFile(file.toString())); + } + + @Test + void createProjectDirectoryIsIdempotent() throws IOException { + Path projectDir = tempDir.resolve("idempotent-project"); + service.createProjectDirectory(projectDir.toString()); + Path result = service.createProjectDirectory(projectDir.toString()); + + assertTrue(Files.exists(result)); + } +} diff --git a/src/test/java/org/frankframework/flow/filesystem/WorkspaceCleanupServiceTest.java b/src/test/java/org/frankframework/flow/filesystem/WorkspaceCleanupServiceTest.java new file mode 100644 index 00000000..0049eecf --- /dev/null +++ b/src/test/java/org/frankframework/flow/filesystem/WorkspaceCleanupServiceTest.java @@ -0,0 +1,121 @@ +package org.frankframework.flow.filesystem; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class WorkspaceCleanupServiceTest { + + private WorkspaceCleanupService cleanupService; + private Path tempWorkspaceRoot; + + @BeforeEach + void setUp() throws IOException { + tempWorkspaceRoot = Files.createTempDirectory("cleanup_test"); + cleanupService = new WorkspaceCleanupService(); + ReflectionTestUtils.setField(cleanupService, "workspaceRootPath", tempWorkspaceRoot.toString()); + ReflectionTestUtils.setField(cleanupService, "retentionHours", 24); + } + + @AfterEach + void tearDown() throws IOException { + if (tempWorkspaceRoot != null && Files.exists(tempWorkspaceRoot)) { + try (var stream = Files.walk(tempWorkspaceRoot)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException ignored) { + } + }); + } + } + } + + @Test + void cleanupDeletesExpiredWorkspaces() throws IOException { + Path expiredSession = Files.createDirectory(tempWorkspaceRoot.resolve("expired-session")); + Files.setLastModifiedTime(expiredSession, FileTime.from(Instant.now().minus(48, ChronoUnit.HOURS))); + + cleanupService.cleanupOldWorkspaces(); + + assertFalse(Files.exists(expiredSession)); + } + + @Test + void cleanupKeepsRecentWorkspaces() throws IOException { + Path recentSession = Files.createDirectory(tempWorkspaceRoot.resolve("recent-session")); + Files.setLastModifiedTime(recentSession, FileTime.from(Instant.now())); + + cleanupService.cleanupOldWorkspaces(); + + assertTrue(Files.exists(recentSession)); + } + + @Test + void cleanupHandlesMixedWorkspaces() throws IOException { + Path expired = Files.createDirectory(tempWorkspaceRoot.resolve("old")); + Files.setLastModifiedTime(expired, FileTime.from(Instant.now().minus(48, ChronoUnit.HOURS))); + + Path recent = Files.createDirectory(tempWorkspaceRoot.resolve("new")); + Files.setLastModifiedTime(recent, FileTime.from(Instant.now())); + + cleanupService.cleanupOldWorkspaces(); + + assertFalse(Files.exists(expired)); + assertTrue(Files.exists(recent)); + } + + @Test + void cleanupSkipsWhenRootDoesNotExist() { + ReflectionTestUtils.setField(cleanupService, "workspaceRootPath", "/nonexistent/workspace/root"); + assertDoesNotThrow(() -> cleanupService.cleanupOldWorkspaces()); + } + + @Test + void cleanupIgnoresFiles() throws IOException { + Files.writeString(tempWorkspaceRoot.resolve("not-a-dir.txt"), "data"); + + assertDoesNotThrow(() -> cleanupService.cleanupOldWorkspaces()); + assertTrue(Files.exists(tempWorkspaceRoot.resolve("not-a-dir.txt"))); + } + + @Test + void cleanupDeletesExpiredWorkspacesWithContents() throws IOException { + Path expiredSession = Files.createDirectory(tempWorkspaceRoot.resolve("expired-with-files")); + Files.writeString(expiredSession.resolve("project-file.xml"), ""); + Files.createDirectory(expiredSession.resolve("subdir")); + Files.writeString(expiredSession.resolve("subdir/nested.txt"), "data"); + Files.setLastModifiedTime(expiredSession, FileTime.from(Instant.now().minus(48, ChronoUnit.HOURS))); + + cleanupService.cleanupOldWorkspaces(); + + assertFalse(Files.exists(expiredSession)); + } + + @Test + void cleanupRespectsCustomRetentionHours() throws IOException { + ReflectionTestUtils.setField(cleanupService, "retentionHours", 1); + + Path session = Files.createDirectory(tempWorkspaceRoot.resolve("session")); + Files.setLastModifiedTime(session, FileTime.from(Instant.now().minus(2, ChronoUnit.HOURS))); + + cleanupService.cleanupOldWorkspaces(); + + assertFalse(Files.exists(session)); + } + + @Test + void cleanupWithEmptyWorkspaceRoot() throws IOException { + assertDoesNotThrow(() -> cleanupService.cleanupOldWorkspaces()); + } +} diff --git a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java index d48ecb6b..e6e3cc7d 100644 --- a/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java +++ b/src/test/java/org/frankframework/flow/filetree/FileTreeServiceTest.java @@ -1,308 +1,336 @@ package org.frankframework.flow.filetree; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import java.io.IOException; -import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; import java.util.List; import org.frankframework.flow.adapter.AdapterNotFoundException; import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.frankframework.flow.project.Project; +import org.frankframework.flow.project.ProjectNotFoundException; import org.frankframework.flow.project.ProjectService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +/** + * Validates filesystem operations, recursive tree building, and XML adapter updates. + */ @ExtendWith(MockitoExtension.class) -class FileTreeServiceTest { +public class FileTreeServiceTest { @Mock private ProjectService projectService; - private FileTreeService fileTreeService; - private Path tempRoot; - - @BeforeEach - void setUp() throws IOException { - tempRoot = Files.createTempDirectory("testProjectRoot"); + @Mock + private FileSystemStorage fileSystemStorage; - when(projectService.getProjectsRoot()).thenReturn(tempRoot); + private FileTreeService fileTreeService; - fileTreeService = new FileTreeService(projectService); + private Path tempProjectRoot; + private static final String TEST_PROJECT_NAME = "FrankFlowTestProject"; - Files.createDirectory(tempRoot.resolve("ProjectA")); - Files.createDirectory(tempRoot.resolve("ProjectB")); - Files.writeString(tempRoot.resolve("ProjectA/config1.xml"), "original"); + @BeforeEach + public void setUp() throws IOException { + tempProjectRoot = Files.createTempDirectory("flow_unit_test"); + fileTreeService = new FileTreeService(projectService, fileSystemStorage); } @AfterEach - void tearDown() throws IOException { - if (tempRoot != null && Files.exists(tempRoot)) { - Files.walk(tempRoot).sorted((a, b) -> b.compareTo(a)).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - // ignore - } - }); + public void tearDown() throws IOException { + if (tempProjectRoot != null && Files.exists(tempProjectRoot)) { + try (var stream = Files.walk(tempProjectRoot)) { + stream.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException ignored) { + } + }); + } } } - @Test - void listProjectFoldersReturnsAllFolders() throws IOException { - List folders = fileTreeService.listProjectFolders(); - - assertEquals(2, folders.size()); - assertTrue(folders.contains("ProjectA")); - assertTrue(folders.contains("ProjectB")); + private void stubToAbsolutePath() throws IOException { + when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + return Paths.get(path); + }); } - @Test - void listProjectFoldersThrowsIfRootDoesNotExist() { - Path nonExistentPath = Paths.get("some/nonexistent/path"); - when(projectService.getProjectsRoot()).thenReturn(nonExistentPath); - - FileTreeService service = new FileTreeService(projectService); - - IllegalStateException exception = assertThrows(IllegalStateException.class, service::listProjectFolders); + private void stubReadFile() throws IOException { + when(fileSystemStorage.readFile(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + return Files.readString(Paths.get(path)); + }); + } - assertTrue(exception.getMessage().contains("Projects root does not exist or is not a directory")); + private void stubWriteFile() throws IOException { + doAnswer(invocation -> { + String path = invocation.getArgument(0); + String content = invocation.getArgument(1); + Files.writeString(Paths.get(path), content); + return null; + }) + .when(fileSystemStorage) + .writeFile(anyString(), anyString()); } @Test - void listProjectFoldersThrowsIfRootIsAFile() throws IOException { - Path tempFile = Files.createTempFile("not-a-directory", ".txt"); - - when(projectService.getProjectsRoot()).thenReturn(tempFile); + @DisplayName("Should correctly read content from an existing file") + public void readFileContent_Success() throws IOException { + stubToAbsolutePath(); + stubReadFile(); - FileTreeService service = new FileTreeService(projectService); + Path file = tempProjectRoot.resolve("test.xml"); + String content = "data"; + Files.writeString(file, content, StandardCharsets.UTF_8); - IllegalStateException exception = assertThrows(IllegalStateException.class, service::listProjectFolders); - - assertTrue(exception.getMessage().contains("Projects root does not exist or is not a directory")); - - Files.deleteIfExists(tempFile); + String result = fileTreeService.readFileContent(file.toAbsolutePath().toString()); + assertEquals(content, result); } @Test - void readFileContentReturnsContentWhenFileExists() throws IOException { - Path file = Files.createTempFile(tempRoot, "config", ".xml"); - String expectedContent = "hello"; - Files.writeString(file, expectedContent); + @DisplayName("Should throw NoSuchFileException when file does not exist") + public void readFileContent_FileNotFound() throws IOException { + stubToAbsolutePath(); - String content = fileTreeService.readFileContent(file.toString()); - assertEquals(expectedContent, content); - - Files.deleteIfExists(file); + String path = tempProjectRoot.resolve("non-existent.xml").toString(); + assertThrows(NoSuchFileException.class, () -> fileTreeService.readFileContent(path)); } @Test - void readFileContentThrowsIfFileOutsideProjectRoot() throws IOException { - Path outsideFile = Files.createTempFile("outside", ".txt"); + @DisplayName("Should throw IllegalArgumentException when path is a directory") + public void readFileContent_IsDirectory() throws IOException { + stubToAbsolutePath(); - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> fileTreeService.readFileContent(outsideFile.toString())); - assertTrue(exception.getMessage().contains("File is outside of projects root")); + Path dir = Files.createDirectory(tempProjectRoot.resolve("subdir")); + String path = dir.toAbsolutePath().toString(); - Files.deleteIfExists(outsideFile); + assertThrows(IllegalArgumentException.class, () -> fileTreeService.readFileContent(path)); } @Test - void readFileContentThrowsIfFileDoesNotExist() { - Path missingFile = tempRoot.resolve("missing.xml"); + @DisplayName("Should successfully overwrite a file with new content") + public void updateFileContent_Success() throws IOException { + stubToAbsolutePath(); + stubWriteFile(); - assertThrows(NoSuchFileException.class, () -> fileTreeService.readFileContent(missingFile.toString())); - } - - @Test - void readFileContentThrowsIfPathIsDirectory() throws IOException { - Path dir = Files.createTempDirectory(tempRoot, "subdir"); + Path file = tempProjectRoot.resolve("update.xml"); + Files.writeString(file, "old content"); - IllegalArgumentException exception = - assertThrows(IllegalArgumentException.class, () -> fileTreeService.readFileContent(dir.toString())); - assertTrue(exception.getMessage().contains("Requested path is a directory")); + String newContent = "new content"; + fileTreeService.updateFileContent(file.toAbsolutePath().toString(), newContent); - Files.deleteIfExists(dir); + assertEquals(newContent, Files.readString(file)); } @Test - void updateFileContentWritesNewContent() throws IOException { - Path file = Files.createTempFile(tempRoot, "config", ".xml"); - - String initialContent = "old"; - Files.writeString(file, initialContent); - - String newContent = "updated"; - fileTreeService.updateFileContent(file.toString(), newContent); + @DisplayName("Should fail when updating a non-existent file") + public void updateFileContent_MissingFile() throws IOException { + stubToAbsolutePath(); - String readBack = Files.readString(file); - assertEquals(newContent, readBack); - - Files.deleteIfExists(file); + String path = tempProjectRoot.resolve("missing-file.xml").toString(); + assertThrows(IllegalArgumentException.class, () -> fileTreeService.updateFileContent(path, "data")); } @Test - void updateFileContentThrowsIfFileOutsideProjectRoot() throws IOException { - Path outsideFile = Files.createTempFile("outside", ".txt"); + @DisplayName("Should build a recursive tree structure for deep directories") + public void getProjectTree_DeepStructure() throws IOException, ProjectNotFoundException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> fileTreeService.updateFileContent(outsideFile.toString(), "content")); - assertTrue(exception.getMessage().contains("File is outside of projects root")); + Files.writeString(tempProjectRoot.resolve("fileA.xml"), "A"); + Path dir1 = Files.createDirectory(tempProjectRoot.resolve("dir1")); + Files.writeString(dir1.resolve("fileB.xml"), "B"); + Path dir2 = Files.createDirectory(dir1.resolve("dir2")); + Files.writeString(dir2.resolve("fileC.xml"), "C"); - Files.deleteIfExists(outsideFile); - } + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); - @Test - void updateFileContentThrowsIfFileDoesNotExist() { - Path missingFile = tempRoot.resolve("missing.xml"); + FileTreeNode tree = fileTreeService.getProjectTree(TEST_PROJECT_NAME); - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> fileTreeService.updateFileContent(missingFile.toString(), "content")); - assertTrue(exception.getMessage().contains("File does not exist")); - } + assertNotNull(tree); + assertEquals(tempProjectRoot.getFileName().toString(), tree.getName()); - @Test - void updateFileContentThrowsIfPathIsDirectory() throws IOException { - Path dir = Files.createTempDirectory(tempRoot, "subdir"); + List children = tree.getChildren(); + assertTrue(children.stream().anyMatch(n -> n.getName().equals("fileA.xml"))); - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, () -> fileTreeService.updateFileContent(dir.toString(), "content")); - assertTrue(exception.getMessage().contains("Cannot update a directory")); + FileTreeNode nodeDir1 = children.stream() + .filter(n -> n.getName().equals("dir1")) + .findFirst() + .orElseThrow(); + assertEquals(2, nodeDir1.getChildren().size()); - Files.deleteIfExists(dir); + FileTreeNode nodeDir2 = nodeDir1.getChildren().stream() + .filter(n -> n.getName().equals("dir2")) + .findFirst() + .orElseThrow(); + assertEquals("fileC.xml", nodeDir2.getChildren().getFirst().getName()); } @Test - void getProjectTreeBuildsTreeCorrectly() throws IOException { - FileTreeNode tree = fileTreeService.getProjectTree("ProjectA"); - - assertEquals("ProjectA", tree.getName()); - assertEquals(1, tree.getChildren().size()); - assertEquals("config1.xml", tree.getChildren().get(0).getName()); + @DisplayName("Should fail if the project is not registered in ProjectService") + public void getProjectTree_ProjectMissing() throws ProjectNotFoundException { + when(projectService.getProject("Unknown")).thenThrow(new ProjectNotFoundException("err")); + assertThrows(IllegalArgumentException.class, () -> fileTreeService.getProjectTree("Unknown")); } @Test - void getProjectTreeThrowsIfProjectDoesNotExist() { + void getProjectTreeThrowsIfProjectDoesNotExist() throws ProjectNotFoundException { + when(projectService.getProject("NonExistentProject")).thenThrow(new ProjectNotFoundException("err")); IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> fileTreeService.getProjectTree("NonExistentProject")); assertTrue(exception.getMessage().contains("Project does not exist: NonExistentProject")); } @Test - void updateAdapterFromFileReturnsFalseIfInvalidXml() - throws IOException, AdapterNotFoundException, ConfigurationNotFoundException { - Path filePath = tempRoot.resolve("ProjectA/config1.xml"); + @DisplayName("Should replace a specific adapter XML block in a configuration file") + public void updateAdapterFromFile_Success() throws Exception { + stubToAbsolutePath(); - // Provide malformed XML - boolean result = fileTreeService.updateAdapterFromFile("ProjectA", filePath, "MyAdapter", ""; + Files.writeString(configFile, originalXml); - assertFalse(result, "Malformed XML should return false"); - } + String newAdapterXml = ""; - @Test - void updateAdapterFromFileThrowsIfAdapterNotFound() { - Path filePath = tempRoot.resolve("ProjectA/config1.xml"); - - AdapterNotFoundException thrown = assertThrows( - AdapterNotFoundException.class, - () -> fileTreeService.updateAdapterFromFile( - "ProjectA", filePath, "NonExistentAdapter", "")); + boolean result = fileTreeService.updateAdapterFromFile(TEST_PROJECT_NAME, configFile, "Test", newAdapterXml); - assertTrue(thrown.getMessage().contains("Adapter not found")); + assertTrue(result); + String updatedXml = Files.readString(configFile); + assertTrue(updatedXml.contains("")); } @Test - void getProjectsRootReturnsCorrectPath() { - Path root = fileTreeService.getProjectsRoot(); - assertEquals(tempRoot.toAbsolutePath(), root.toAbsolutePath()); + @DisplayName("Should throw AdapterNotFoundException if adapter name is missing") + void updateAdapterFromFile_AdapterNotFound() throws IOException { + stubToAbsolutePath(); + + Path configFile = tempProjectRoot.resolve("config.xml"); + Files.writeString(configFile, ""); + + assertThrows( + AdapterNotFoundException.class, + () -> fileTreeService.updateAdapterFromFile(TEST_PROJECT_NAME, configFile, "Target", "")); } @Test - void getProjectsRootThrowsIfRootDoesNotExist() { - // Mock ProjectService to return a non-existent path - Path nonExistentPath = Paths.get("some/nonexistent/path"); - when(projectService.getProjectsRoot()).thenReturn(nonExistentPath); + @DisplayName("Should return false if the new adapter XML is malformed") + public void updateAdapterFromFile_MalformedXml() + throws IOException, AdapterNotFoundException, ConfigurationNotFoundException { + stubToAbsolutePath(); - FileTreeService service = new FileTreeService(projectService); + Path configFile = tempProjectRoot.resolve("config.xml"); + Files.writeString(configFile, ""); - IllegalStateException exception = assertThrows(IllegalStateException.class, service::getProjectsRoot); + String badXml = ""); + Files.writeString(tempProjectRoot.resolve("readme.txt"), "hello"); - FileTreeNode node = fileTreeService.getShallowDirectoryTree("ProjectA", "."); + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode node = fileTreeService.getShallowDirectoryTree(TEST_PROJECT_NAME, "."); assertNotNull(node); - assertEquals("ProjectA", node.getName()); assertEquals(NodeType.DIRECTORY, node.getType()); assertNotNull(node.getChildren()); - - // We expect two children now: config1.xml and readme.txt assertEquals(2, node.getChildren().size()); - // Verify children names assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("config1.xml"))); assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("readme.txt"))); } @Test - void getShallowDirectoryTreeThrowsSecurityExceptionForPathTraversal() { + void getShallowDirectoryTreeThrowsSecurityExceptionForPathTraversal() throws ProjectNotFoundException, IOException { + stubToAbsolutePath(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + SecurityException ex = assertThrows( - SecurityException.class, () -> fileTreeService.getShallowDirectoryTree("ProjectA", "../ProjectB")); + SecurityException.class, () -> fileTreeService.getShallowDirectoryTree(TEST_PROJECT_NAME, "../other")); assertTrue(ex.getMessage().contains("Invalid path")); } @Test - void getShallowDirectoryTreeThrowsIllegalArgumentExceptionIfDirectoryDoesNotExist() { + void getShallowDirectoryTreeThrowsIllegalArgumentExceptionIfDirectoryDoesNotExist() + throws ProjectNotFoundException, IOException { + stubToAbsolutePath(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> fileTreeService.getShallowDirectoryTree("ProjectA", "nonexistent")); + () -> fileTreeService.getShallowDirectoryTree(TEST_PROJECT_NAME, "nonexistent")); assertTrue(ex.getMessage().contains("Directory does not exist")); } @Test - public void getShallowConfigurationsDirectoryTreeReturnsTreeForExistingDirectory() throws IOException { - // Move the existing config1.xml into the expected configurations folder - Path configsDir = tempRoot.resolve("ProjectA/src/main/configurations"); - Files.createDirectories(configsDir); - Files.move( - tempRoot.resolve("ProjectA/config1.xml"), - configsDir.resolve("config1.xml"), - StandardCopyOption.REPLACE_EXISTING); + public void getShallowConfigurationsDirectoryTreeReturnsTreeForExistingDirectory() + throws IOException, ProjectNotFoundException { + stubToAbsolutePath(); + Path configsDir = tempProjectRoot.resolve("src/main/configurations"); + Files.createDirectories(configsDir); + Files.writeString(configsDir.resolve("config1.xml"), ""); Files.writeString(configsDir.resolve("readme.txt"), "hello"); - FileTreeNode node = fileTreeService.getShallowConfigurationsDirectoryTree("ProjectA"); + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode node = fileTreeService.getShallowConfigurationsDirectoryTree(TEST_PROJECT_NAME); assertNotNull(node); assertEquals("configurations", node.getName().toLowerCase()); @@ -315,18 +343,25 @@ public void getShallowConfigurationsDirectoryTreeReturnsTreeForExistingDirectory } @Test - public void getShallowConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() { - // No src/main/configurations created for ProjectB + public void getShallowConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() + throws ProjectNotFoundException, IOException { + stubToAbsolutePath(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, - () -> fileTreeService.getShallowConfigurationsDirectoryTree("ProjectB")); + () -> fileTreeService.getShallowConfigurationsDirectoryTree(TEST_PROJECT_NAME)); assertTrue(ex.getMessage().contains("Configurations directory does not exist")); } @Test - public void getShallowConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() { - // Project does not exist + public void getShallowConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() throws ProjectNotFoundException { + when(projectService.getProject("NonExistentProject")).thenThrow(new ProjectNotFoundException("err")); + IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> fileTreeService.getShallowConfigurationsDirectoryTree("NonExistentProject")); @@ -335,57 +370,63 @@ public void getShallowConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() { } @Test - public void getConfigurationsDirectoryTreeReturnsFullTreeForExistingDirectory() throws IOException { - // Reuse the existing setup: create the configurations folder - Path configsDir = tempRoot.resolve("ProjectA/src/main/configurations"); - Files.createDirectories(configsDir); - - // Move existing config1.xml into this folder - Files.move( - tempRoot.resolve("ProjectA/config1.xml"), - configsDir.resolve("config1.xml"), - StandardCopyOption.REPLACE_EXISTING); + public void getConfigurationsDirectoryTreeReturnsFullTreeForExistingDirectory() + throws IOException, ProjectNotFoundException { + stubToAbsolutePath(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); - // Add an extra file and subdirectory to test recursion + Path configsDir = tempProjectRoot.resolve("src/main/configurations"); + Files.createDirectories(configsDir); + Files.writeString(configsDir.resolve("config1.xml"), ""); Files.writeString(configsDir.resolve("readme.txt"), "hello"); Path subDir = configsDir.resolve("subconfigs"); Files.createDirectory(subDir); Files.writeString(subDir.resolve("nested.xml"), ""); - FileTreeNode node = fileTreeService.getConfigurationsDirectoryTree("ProjectA"); + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + + FileTreeNode node = fileTreeService.getConfigurationsDirectoryTree(TEST_PROJECT_NAME); assertNotNull(node); assertEquals("configurations", node.getName().toLowerCase()); assertEquals(NodeType.DIRECTORY, node.getType()); assertNotNull(node.getChildren()); - assertEquals(3, node.getChildren().size()); // config1.xml, readme.txt, subconfigs + assertEquals(3, node.getChildren().size()); - // Check for files assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("config1.xml"))); assertTrue(node.getChildren().stream().anyMatch(c -> c.getName().equals("readme.txt"))); - // Check for subdirectory FileTreeNode subConfigNode = node.getChildren().stream() .filter(c -> c.getName().equals("subconfigs")) .findFirst() .orElseThrow(); assertEquals(NodeType.DIRECTORY, subConfigNode.getType()); assertEquals(1, subConfigNode.getChildren().size()); - assertEquals("nested.xml", subConfigNode.getChildren().get(0).getName()); + assertEquals("nested.xml", subConfigNode.getChildren().getFirst().getName()); } @Test - public void getConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() { - // The "src/main/configurations" folder does NOT exist yet + public void getConfigurationsDirectoryTreeThrowsIfDirectoryDoesNotExist() + throws ProjectNotFoundException, IOException { + stubToAbsolutePath(); + + Project project = + new Project(TEST_PROJECT_NAME, tempProjectRoot.toAbsolutePath().toString()); + when(projectService.getProject(TEST_PROJECT_NAME)).thenReturn(project); + IllegalArgumentException ex = assertThrows( - IllegalArgumentException.class, () -> fileTreeService.getConfigurationsDirectoryTree("ProjectA")); + IllegalArgumentException.class, + () -> fileTreeService.getConfigurationsDirectoryTree(TEST_PROJECT_NAME)); assertTrue(ex.getMessage().contains("Configurations directory does not exist")); } @Test - public void getConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() { - // Project folder itself does not exist + public void getConfigurationsDirectoryTreeThrowsIfProjectDoesNotExist() throws ProjectNotFoundException { + when(projectService.getProject("NonExistingProject")).thenThrow(new ProjectNotFoundException("err")); + IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> fileTreeService.getConfigurationsDirectoryTree("NonExistingProject")); diff --git a/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java b/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java index e2b57c89..ff1df8ee 100644 --- a/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java +++ b/src/test/java/org/frankframework/flow/project/ProjectControllerTest.java @@ -6,28 +6,34 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.io.OutputStream; import java.nio.file.NoSuchFileException; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Map; import org.frankframework.flow.configuration.Configuration; +import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.filetree.FileTreeService; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; import org.frankframework.flow.projectsettings.ProjectSettings; +import org.frankframework.flow.recentproject.RecentProjectsService; import org.frankframework.flow.utility.XmlValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @WebMvcTest(ProjectController.class) +@AutoConfigureMockMvc(addFilters = false) class ProjectControllerTest { @Autowired @@ -39,9 +45,28 @@ class ProjectControllerTest { @MockitoBean private FileTreeService fileTreeService; + @MockitoBean + private RecentProjectsService recentProjectsService; + + @MockitoBean + private FileSystemStorage fileSystemStorage; + + @MockitoBean + private org.frankframework.flow.security.UserContextFilter userContextFilter; + + @MockitoBean + private org.frankframework.flow.security.UserWorkspaceContext userWorkspaceContext; + + @BeforeEach + void setUp() { + Mockito.reset(projectService); + when(fileSystemStorage.toRelativePath(anyString())).thenAnswer(inv -> inv.getArgument(0)); + } + private Project mockProject() { Project project = mock(Project.class); when(project.getName()).thenReturn("MyProject"); + when(project.getRootPath()).thenReturn("/path/to/MyProject"); Configuration config = mock(Configuration.class); when(config.getFilepath()).thenReturn("config1.xml"); @@ -59,11 +84,6 @@ private Project mockProject() { return project; } - @BeforeEach - void resetMocks() { - Mockito.reset(projectService); - } - @Test void getAllProjectsReturnsExpectedJson() throws Exception { Project project = mockProject(); @@ -72,6 +92,7 @@ void getAllProjectsReturnsExpectedJson() throws Exception { mockMvc.perform(get("/api/projects").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].name").value("MyProject")) + .andExpect(jsonPath("$[0].rootPath").value("/path/to/MyProject")) .andExpect(jsonPath("$[0].filepaths[0]").value("config1.xml")) .andExpect(jsonPath("$[0].filters.ADAPTER").value(true)) .andExpect(jsonPath("$[0].filters.AMQP").value(false)); @@ -87,6 +108,7 @@ void getProjectReturnsExpectedJson() throws Exception { mockMvc.perform(get("/api/projects/MyProject").accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("MyProject")) + .andExpect(jsonPath("$.rootPath").value("/path/to/MyProject")) .andExpect(jsonPath("$.filepaths[0]").value("config1.xml")) .andExpect(jsonPath("$.filters.ADAPTER").value(true)) .andExpect(jsonPath("$.filters.AMQP").value(false)); @@ -123,7 +145,6 @@ void getConfigurationByPathReturnsExpectedJson() throws Exception { .andExpect(jsonPath("$.filepath").value(filepath)) .andExpect(jsonPath("$.content").value(xmlContent)); - // Verify verify(fileTreeService).readFileContent(filepath); } @@ -283,12 +304,13 @@ void updateAdapterFromFileNotFoundReturns404() throws Exception { @Test void createProjectReturnsProjectDto() throws Exception { - String projectName = "NewProject"; String rootPath = "/path/to/new/project"; + Project project = mockProject(); + when(project.getName()).thenReturn("NewProject"); + when(project.getRootPath()).thenReturn(rootPath); + when(project.getConfigurations()).thenReturn(new ArrayList<>()); - Project createdProject = new Project(projectName, rootPath); - - when(projectService.createProject(projectName, rootPath)).thenReturn(createdProject); + when(projectService.createProjectOnDisk(rootPath)).thenReturn(project); mockMvc.perform( post("/api/projects") @@ -297,16 +319,15 @@ void createProjectReturnsProjectDto() throws Exception { .content( """ { - "name": "NewProject", "rootPath": "/path/to/new/project" } """)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value(projectName)) - .andExpect(jsonPath("$.filepaths").isEmpty()) - .andExpect(jsonPath("$.filters").isNotEmpty()); + .andExpect(jsonPath("$.name").value("NewProject")) + .andExpect(jsonPath("$.rootPath").value(rootPath)); - verify(projectService).createProject(projectName, rootPath); + verify(projectService).createProjectOnDisk(rootPath); + verify(recentProjectsService).addRecentProject("NewProject", rootPath); } @Test @@ -319,6 +340,7 @@ void enableFilterTogglesFilterToTrue() throws Exception { Project updatedProject = mock(Project.class); when(updatedProject.getName()).thenReturn("MyProject"); + when(updatedProject.getRootPath()).thenReturn("/path/to/MyProject"); ArrayList configs = new ArrayList<>(project.getConfigurations()); when(updatedProject.getConfigurations()).thenReturn(configs); @@ -377,6 +399,7 @@ void disableFilterTogglesFilterToFalse() throws Exception { Project updatedProject = mock(Project.class); when(updatedProject.getName()).thenReturn("MyProject"); + when(updatedProject.getRootPath()).thenReturn("/path/to/MyProject"); ArrayList configs = new ArrayList<>(project.getConfigurations()); when(updatedProject.getConfigurations()).thenReturn(configs); @@ -424,4 +447,75 @@ void disableFilterInvalidFilterTypeReturns400() throws Exception { verify(projectService).disableFilter("MyProject", filterType); } + + @Test + void exportProjectReturnsZipFile() throws Exception { + doAnswer(invocation -> { + OutputStream os = invocation.getArgument(1); + os.write("fake-zip-content".getBytes()); + return null; + }) + .when(projectService) + .exportProjectAsZip(eq("MyProject"), any(OutputStream.class)); + + mockMvc.perform(get("/api/projects/MyProject/export")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"MyProject.zip\"")) + .andExpect(content().contentType("application/zip")); + + verify(projectService).exportProjectAsZip(eq("MyProject"), any(OutputStream.class)); + } + + @Test + void exportProjectNotFoundReturns404() throws Exception { + doThrow(new ProjectNotFoundException("Project not found")) + .when(projectService) + .exportProjectAsZip(eq("Unknown"), any(OutputStream.class)); + + mockMvc.perform(get("/api/projects/Unknown/export")).andExpect(status().isNotFound()); + + verify(projectService).exportProjectAsZip(eq("Unknown"), any(OutputStream.class)); + } + + @Test + void importProjectReturnsProjectDto() throws Exception { + Project project = mockProject(); + when(project.getName()).thenReturn("ImportedProject"); + when(project.getRootPath()).thenReturn("/path/to/ImportedProject"); + when(project.getConfigurations()).thenReturn(new ArrayList<>()); + + when(projectService.importProjectFromFiles(eq("ImportedProject"), anyList(), anyList())) + .thenReturn(project); + + MockMultipartFile file1 = new MockMultipartFile( + "files", "Configuration.xml", MediaType.APPLICATION_XML_VALUE, "test".getBytes()); + MockMultipartFile file2 = + new MockMultipartFile("files", "pom.xml", MediaType.APPLICATION_XML_VALUE, "".getBytes()); + + mockMvc.perform(multipart("/api/projects/import") + .file(file1) + .file(file2) + .param("paths", "src/main/configurations/Configuration.xml", "pom.xml") + .param("projectName", "ImportedProject")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("ImportedProject")) + .andExpect(jsonPath("$.rootPath").value("/path/to/ImportedProject")); + + verify(projectService).importProjectFromFiles(eq("ImportedProject"), anyList(), anyList()); + verify(recentProjectsService).addRecentProject("ImportedProject", "/path/to/ImportedProject"); + } + + @Test + void importProjectWithMismatchedFilesAndPathsReturnsBadRequest() throws Exception { + MockMultipartFile file1 = new MockMultipartFile( + "files", "Configuration.xml", MediaType.APPLICATION_XML_VALUE, "test".getBytes()); + + mockMvc.perform(multipart("/api/projects/import") + .file(file1) + .param("paths", "path1.xml", "path2.xml") + .param("projectName", "TestProject")) + .andExpect(status().isBadRequest()); + + verify(projectService, never()).importProjectFromFiles(anyString(), anyList(), anyList()); + } } diff --git a/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java b/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java index d26963c5..547c2574 100644 --- a/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java +++ b/src/test/java/org/frankframework/flow/project/ProjectServiceTest.java @@ -1,90 +1,216 @@ package org.frankframework.flow.project; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import org.frankframework.flow.adapter.AdapterNotFoundException; import org.frankframework.flow.configuration.Configuration; import org.frankframework.flow.configuration.ConfigurationNotFoundException; +import org.frankframework.flow.filesystem.FileSystemStorage; import org.frankframework.flow.projectsettings.FilterType; import org.frankframework.flow.projectsettings.InvalidFilterTypeException; +import org.frankframework.flow.recentproject.RecentProject; +import org.frankframework.flow.recentproject.RecentProjectsService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; @ExtendWith(MockitoExtension.class) -class ProjectServiceTest { +public class ProjectServiceTest { + + private ProjectService projectService; @Mock - private ResourcePatternResolver resolver; + private FileSystemStorage fileSystemStorage; - private ProjectService projectService; + @Mock + private RecentProjectsService recentProjectsService; + + @TempDir + Path tempDir; + + private final List recentProjects = new ArrayList<>(); @BeforeEach - void init() throws IOException { - projectService = new ProjectService(resolver, "/path/to/projects"); + void init() { + recentProjects.clear(); + projectService = new ProjectService(fileSystemStorage, recentProjectsService); + } + + /** + * Sets up all stubs needed for tests that create projects on disk and then interact with them. + */ + private void stubFileSystemForProjectCreation() throws IOException { + when(fileSystemStorage.createProjectDirectory(anyString())).thenAnswer(invocation -> { + String dirName = invocation.getArgument(0); + Path dirPath = Path.of(dirName); + String projectName = dirPath.getFileName().toString(); + Path projectDir = tempDir.resolve(projectName); + Files.createDirectories(projectDir); + return projectDir; + }); + + when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + Path p = Path.of(path); + if (p.isAbsolute()) { + return p; + } + return tempDir.resolve(path); + }); + + doAnswer(invocation -> { + String path = invocation.getArgument(0); + String content = invocation.getArgument(1); + Path filePath = Path.of(path); + if (filePath.getParent() != null) { + Files.createDirectories(filePath.getParent()); + } + Files.writeString(filePath, content, StandardCharsets.UTF_8); + return null; + }) + .when(fileSystemStorage) + .writeFile(anyString(), anyString()); + + when(fileSystemStorage.readFile(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + return Files.readString(Path.of(path), StandardCharsets.UTF_8); + }); } @Test - void testAddingProjectToProjectService() throws ProjectNotFoundException, ProjectAlreadyExistsException { + public void testAddingProjectToProjectService() throws ProjectNotFoundException, IOException { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + stubFileSystemForProjectCreation(); + String projectName = "new_project"; - String rootPath = "/path/to/new_project"; assertEquals(0, projectService.getProjects().size()); assertThrows(ProjectNotFoundException.class, () -> projectService.getProject(projectName)); - projectService.createProject(projectName, rootPath); + projectService.createProjectOnDisk(projectName); - assertEquals(1, projectService.getProjects().size()); + // After creation the project is cached, so getProject should find it assertNotNull(projectService.getProject(projectName)); } @Test - void testAddingProjectThrowsProjectAlreadyExists() throws ProjectAlreadyExistsException { - String projectName = "existing_project"; - String rootPath = "/path/to/existing_project"; + public void testCreateProjectOnDiskCreatesDirectoryStructure() throws IOException { + stubFileSystemForProjectCreation(); + + String projectName = "test_proj"; + + Project project = projectService.createProjectOnDisk(projectName); + + assertNotNull(project); + assertEquals(projectName, project.getName()); + + Path configDir = tempDir.resolve(projectName).resolve("src/main/configurations"); + assertTrue(Files.exists(configDir), "configurations directory should exist"); + + Path configFile = configDir.resolve("Configuration.xml"); + assertTrue(Files.exists(configFile), "Configuration.xml should exist"); + String content = Files.readString(configFile, StandardCharsets.UTF_8); + assertTrue(content.contains("DefaultConfig"), "Default configuration content should be written"); + } + + @Test + public void testCreateProjectOnDiskLoadsConfiguration() throws IOException, ProjectNotFoundException { + stubFileSystemForProjectCreation(); - projectService.createProject(projectName, rootPath); + String projectName = "loaded_proj"; - assertThrows(ProjectAlreadyExistsException.class, () -> projectService.createProject(projectName, rootPath)); + projectService.createProjectOnDisk(projectName); + + Project project = projectService.getProject(projectName); + assertNotNull(project); + assertFalse(project.getConfigurations().isEmpty(), "Project should have at least one configuration loaded"); } @Test - void testGetProjectThrowsProjectNotFound() { + public void testGetProjectThrowsProjectNotFound() { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + assertThrows(ProjectNotFoundException.class, () -> projectService.getProject("missingProject")); } @Test - void testUpdateConfigurationXmlSuccess() throws Exception { - projectService.createProject("proj", "/path/to/proj"); + public void testGetProjectsReturnsEmptyListInitially() { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + + List projects = projectService.getProjects(); + assertEquals(0, projects.size()); + } + + @Test + public void testGetProjectsFromRecentList() throws IOException { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("my_project"); + + Path projectDir = tempDir.resolve("my_project"); + recentProjects.add(new RecentProject("my_project", projectDir.toString(), "2026-01-01T00:00:00Z")); + + projectService.invalidateCache(); + + List projects = projectService.getProjects(); + assertEquals(1, projects.size()); + assertEquals("my_project", projects.getFirst().getName()); + } + + @Test + public void testUpdateConfigurationXmlSuccess() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); Project project = projectService.getProject("proj"); - Configuration config = new Configuration("config.xml"); - project.getConfigurations().add(config); + assertFalse(project.getConfigurations().isEmpty()); + Configuration config = project.getConfigurations().get(0); + String filepath = config.getFilepath(); - boolean updated = projectService.updateConfigurationXml("proj", "config.xml", ""); + boolean updated = projectService.updateConfigurationXml("proj", filepath, ""); assertTrue(updated); assertEquals("", config.getXmlContent()); } @Test - void testUpdateConfigurationXmlThrowsProjectNotFound() { + public void testUpdateConfigurationXmlThrowsProjectNotFound() { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + assertThrows( ProjectNotFoundException.class, () -> projectService.updateConfigurationXml("unknownProject", "config.xml", "")); } @Test - void testUpdateConfigurationXmlConfigNotFound() throws Exception { - projectService.createProject("proj", "/path/to/proj"); + public void testUpdateConfigurationXmlConfigNotFound() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); assertThrows( ConfigurationNotFoundException.class, @@ -92,8 +218,10 @@ void testUpdateConfigurationXmlConfigNotFound() throws Exception { } @Test - void testEnableFilterValid() throws Exception { - projectService.createProject("proj", "/path/to/proj"); + public void testEnableFilterValid() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); Project project = projectService.enableFilter("proj", "ADAPTER"); @@ -101,10 +229,11 @@ void testEnableFilterValid() throws Exception { } @Test - void testDisableFilterValid() throws Exception { - projectService.createProject("proj", "/path/to/proj"); + public void testDisableFilterValid() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); - // enable first projectService.enableFilter("proj", "ADAPTER"); assertTrue(projectService .getProject("proj") @@ -112,14 +241,15 @@ void testDisableFilterValid() throws Exception { .getFilters() .get(FilterType.ADAPTER)); - // disable Project updated = projectService.disableFilter("proj", "ADAPTER"); assertFalse(updated.getProjectSettings().getFilters().get(FilterType.ADAPTER)); } @Test - void testEnableFilterInvalidFilterType() throws ProjectAlreadyExistsException { - projectService.createProject("proj", "/path/to/proj"); + public void testEnableFilterInvalidFilterType() throws IOException { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); InvalidFilterTypeException ex = assertThrows( InvalidFilterTypeException.class, () -> projectService.enableFilter("proj", "INVALID_TYPE")); @@ -128,8 +258,10 @@ void testEnableFilterInvalidFilterType() throws ProjectAlreadyExistsException { } @Test - void testDisableFilterInvalidFilterType() throws ProjectAlreadyExistsException { - projectService.createProject("proj", "/path/to/proj"); + public void testDisableFilterInvalidFilterType() throws IOException { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); InvalidFilterTypeException ex = assertThrows( InvalidFilterTypeException.class, () -> projectService.disableFilter("proj", "INVALID_TYPE")); @@ -138,25 +270,32 @@ void testDisableFilterInvalidFilterType() throws ProjectAlreadyExistsException { } @Test - void testEnableFilterProjectNotFound() { + public void testEnableFilterProjectNotFound() { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + ProjectNotFoundException ex = assertThrows( ProjectNotFoundException.class, () -> projectService.enableFilter("unknownProject", "ADAPTER")); - assertTrue(ex.getMessage().contains("Project with name: unknownProject")); + assertTrue(ex.getMessage().contains("unknownProject")); } @Test - void testDisableFilterProjectNotFound() { + public void testDisableFilterProjectNotFound() { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + ProjectNotFoundException ex = assertThrows( ProjectNotFoundException.class, () -> projectService.disableFilter("unknownProject", "ADAPTER")); - assertTrue(ex.getMessage().contains("Project with name: unknownProject")); + assertTrue(ex.getMessage().contains("unknownProject")); } @Test - void updateAdapterSuccess() throws Exception { - // Arrange - projectService.createProject("proj", "/path/to/proj"); + public void updateAdapterSuccess() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); Project project = projectService.getProject("proj"); String originalXml = @@ -182,10 +321,8 @@ void updateAdapterSuccess() throws Exception { """; - // Act boolean result = projectService.updateAdapter("proj", "conf.xml", "A1", newAdapterXml); - // Assert assertTrue(result); String updatedXml = config.getXmlContent(); assertTrue(updatedXml.contains("999")); @@ -194,16 +331,21 @@ void updateAdapterSuccess() throws Exception { } @Test - void updateAdapterProjectNotFoundThrows() { + public void updateAdapterProjectNotFoundThrows() { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + ProjectNotFoundException ex = assertThrows(ProjectNotFoundException.class, () -> { projectService.updateAdapter("unknownProject", "conf.xml", "A1", ""); }); - assertTrue(ex.getMessage().contains("Project with name: unknownProject")); + assertTrue(ex.getMessage().contains("unknownProject")); } @Test - void updateAdapterConfigurationNotFoundThrows() throws Exception { - projectService.createProject("proj", "/path/to/proj"); + public void updateAdapterConfigurationNotFoundThrows() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); ConfigurationNotFoundException ex = assertThrows(ConfigurationNotFoundException.class, () -> { projectService.updateAdapter("proj", "missing.xml", "A1", ""); @@ -213,8 +355,10 @@ void updateAdapterConfigurationNotFoundThrows() throws Exception { } @Test - void updateAdapterAdapterNotFoundThrows() throws Exception { - projectService.createProject("proj", "/path/to/proj"); + public void updateAdapterAdapterNotFoundThrows() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); Project project = projectService.getProject("proj"); String xml = @@ -237,8 +381,10 @@ void updateAdapterAdapterNotFoundThrows() throws Exception { } @Test - void updateAdapterInvalidXmlReturnsFalse() throws Exception { - projectService.createProject("proj", "/path/to/proj"); + public void updateAdapterInvalidXmlReturnsFalse() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); Project project = projectService.getProject("proj"); String xml = @@ -259,4 +405,243 @@ void updateAdapterInvalidXmlReturnsFalse() throws Exception { assertFalse(result); assertEquals(xml, config.getXmlContent()); } + + @Test + public void importProjectFromFilesSuccess() throws Exception { + when(fileSystemStorage.createProjectDirectory(anyString())).thenAnswer(invocation -> { + String dirName = invocation.getArgument(0); + Path dirPath = Path.of(dirName); + String projectName = dirPath.getFileName().toString(); + Path projectDir = tempDir.resolve(projectName); + Files.createDirectories(projectDir); + return projectDir; + }); + + when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + Path p = Path.of(path); + if (p.isAbsolute()) { + return p; + } + return tempDir.resolve(path); + }); + + String projectName = "imported_project"; + + MockMultipartFile configFile = new MockMultipartFile( + "files", + "Configuration.xml", + "application/xml", + "".getBytes(StandardCharsets.UTF_8)); + + MockMultipartFile propsFile = new MockMultipartFile( + "files", "application.properties", "text/plain", "key=value".getBytes(StandardCharsets.UTF_8)); + + List files = List.of(configFile, propsFile); + List paths = + List.of("src/main/configurations/Configuration.xml", "src/main/resources/application.properties"); + + Project project = projectService.importProjectFromFiles(projectName, files, paths); + + assertNotNull(project); + assertEquals(projectName, project.getName()); + + Path projectDir = tempDir.resolve(projectName); + Path writtenConfig = projectDir.resolve("src/main/configurations/Configuration.xml"); + assertTrue(Files.exists(writtenConfig), "Configuration.xml should be written to disk"); + String writtenContent = Files.readString(writtenConfig, StandardCharsets.UTF_8); + assertTrue(writtenContent.contains("TestAdapter")); + + Path writtenProps = projectDir.resolve("src/main/resources/application.properties"); + assertTrue(Files.exists(writtenProps), "application.properties should be written to disk"); + assertEquals("key=value", Files.readString(writtenProps, StandardCharsets.UTF_8)); + } + + @Test + public void importProjectFromFilesLoadsConfigurations() throws Exception { + when(fileSystemStorage.createProjectDirectory(anyString())).thenAnswer(invocation -> { + String dirName = invocation.getArgument(0); + Path dirPath = Path.of(dirName); + String projectName = dirPath.getFileName().toString(); + Path projectDir = tempDir.resolve(projectName); + Files.createDirectories(projectDir); + return projectDir; + }); + + when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + Path p = Path.of(path); + if (p.isAbsolute()) { + return p; + } + return tempDir.resolve(path); + }); + + String projectName = "imported_with_configs"; + + String configXml = + """ + + + + """; + + MockMultipartFile configFile = new MockMultipartFile( + "files", "MyConfig.xml", "application/xml", configXml.getBytes(StandardCharsets.UTF_8)); + + List files = List.of(configFile); + List paths = List.of("src/main/configurations/MyConfig.xml"); + + Project project = projectService.importProjectFromFiles(projectName, files, paths); + + assertNotNull(project); + assertFalse(project.getConfigurations().isEmpty(), "Imported project should have configurations loaded"); + } + + @Test + void importProjectFromFilesRejectsPathTraversalWithDoubleDots() throws IOException { + when(fileSystemStorage.createProjectDirectory(anyString())).thenAnswer(invocation -> { + String dirName = invocation.getArgument(0); + Path projectDir = tempDir.resolve(dirName); + Files.createDirectories(projectDir); + return projectDir; + }); + + String projectName = "traversal_project"; + + MockMultipartFile maliciousFile = new MockMultipartFile( + "files", "evil.xml", "application/xml", "".getBytes(StandardCharsets.UTF_8)); + + List files = List.of(maliciousFile); + List paths = List.of("../../../etc/evil.xml"); + + SecurityException ex = assertThrows( + SecurityException.class, () -> projectService.importProjectFromFiles(projectName, files, paths)); + + assertTrue(ex.getMessage().contains("Invalid file path")); + } + + @Test + void importProjectFromFilesRejectsAbsolutePath() throws IOException { + when(fileSystemStorage.createProjectDirectory(anyString())).thenAnswer(invocation -> { + String dirName = invocation.getArgument(0); + Path projectDir = tempDir.resolve(dirName); + Files.createDirectories(projectDir); + return projectDir; + }); + + String projectName = "abs_path_project"; + + MockMultipartFile maliciousFile = new MockMultipartFile( + "files", "evil.xml", "application/xml", "".getBytes(StandardCharsets.UTF_8)); + + List files = List.of(maliciousFile); + List paths = List.of("/etc/passwd"); + + SecurityException ex = assertThrows( + SecurityException.class, () -> projectService.importProjectFromFiles(projectName, files, paths)); + + assertTrue(ex.getMessage().contains("Invalid file path")); + } + + @Test + void importProjectFromFilesRejectsBackslashPathTraversal() throws IOException { + when(fileSystemStorage.createProjectDirectory(anyString())).thenAnswer(invocation -> { + String dirName = invocation.getArgument(0); + Path projectDir = tempDir.resolve(dirName); + Files.createDirectories(projectDir); + return projectDir; + }); + + String projectName = "backslash_project"; + + MockMultipartFile maliciousFile = new MockMultipartFile( + "files", "evil.xml", "application/xml", "".getBytes(StandardCharsets.UTF_8)); + + List files = List.of(maliciousFile); + List paths = List.of("..\\..\\etc\\evil.xml"); + + assertThrows(SecurityException.class, () -> projectService.importProjectFromFiles(projectName, files, paths)); + } + + @Test + void testInvalidateCacheClearsAllProjects() throws Exception { + stubFileSystemForProjectCreation(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + + projectService.createProjectOnDisk("proj1"); + projectService.createProjectOnDisk("proj2"); + + assertNotNull(projectService.getProject("proj1")); + assertNotNull(projectService.getProject("proj2")); + + projectService.invalidateCache(); + + assertThrows(ProjectNotFoundException.class, () -> projectService.getProject("proj1")); + assertThrows(ProjectNotFoundException.class, () -> projectService.getProject("proj2")); + } + + @Test + void testInvalidateProjectRemovesSingleProject() throws Exception { + stubFileSystemForProjectCreation(); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + + projectService.createProjectOnDisk("proj1"); + projectService.createProjectOnDisk("proj2"); + + projectService.invalidateProject("proj1"); + + assertThrows(ProjectNotFoundException.class, () -> projectService.getProject("proj1")); + assertNotNull(projectService.getProject("proj2")); + } + + @Test + void testOpenProjectFromDisk() throws Exception { + when(fileSystemStorage.toAbsolutePath(anyString())).thenAnswer(invocation -> { + String path = invocation.getArgument(0); + Path p = Path.of(path); + if (p.isAbsolute()) { + return p; + } + return tempDir.resolve(path); + }); + + String projectName = "manual_project"; + Path projectDir = tempDir.resolve(projectName); + Files.createDirectories(projectDir.resolve("src/main/configurations")); + Files.writeString( + projectDir.resolve("src/main/configurations/TestConfig.xml"), + "", + StandardCharsets.UTF_8); + + Project project = projectService.openProjectFromDisk(projectDir.toString()); + + assertNotNull(project); + assertEquals(projectName, project.getName()); + assertFalse(project.getConfigurations().isEmpty()); + } + + @Test + void testAddConfigurationToProject() throws Exception { + stubFileSystemForProjectCreation(); + + projectService.createProjectOnDisk("proj"); + + Project project = projectService.addConfiguration("proj", "NewConfig.xml"); + + boolean hasNewConfig = project.getConfigurations().stream() + .anyMatch(c -> c.getFilepath().equals("NewConfig.xml")); + assertTrue(hasNewConfig, "Project should contain the newly added configuration"); + } + + @Test + void testAddConfigurationProjectNotFound() { + when(fileSystemStorage.isLocalEnvironment()).thenReturn(true); + when(recentProjectsService.getRecentProjects()).thenReturn(recentProjects); + + assertThrows( + ProjectNotFoundException.class, () -> projectService.addConfiguration("noSuchProject", "Conf.xml")); + } } diff --git a/src/test/java/org/frankframework/flow/recentproject/RecentProjectsServiceTest.java b/src/test/java/org/frankframework/flow/recentproject/RecentProjectsServiceTest.java new file mode 100644 index 00000000..a1a528d4 --- /dev/null +++ b/src/test/java/org/frankframework/flow/recentproject/RecentProjectsServiceTest.java @@ -0,0 +1,230 @@ +package org.frankframework.flow.recentproject; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import org.frankframework.flow.filesystem.FileSystemStorage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RecentProjectsServiceTest { + + @Mock + private FileSystemStorage fileSystemStorage; + + private RecentProjectsService service; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String storedJson; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() throws IOException { + storedJson = null; + + when(fileSystemStorage.isLocalEnvironment()).thenReturn(false); + + when(fileSystemStorage.readFile(anyString())).thenAnswer(invocation -> { + if (storedJson == null) throw new IOException("File not found"); + return storedJson; + }); + + service = new RecentProjectsService(fileSystemStorage, objectMapper); + } + + private void stubWriteFile() throws IOException { + doAnswer(invocation -> { + storedJson = invocation.getArgument(1); + return null; + }) + .when(fileSystemStorage) + .writeFile(anyString(), anyString()); + } + + @Test + void getRecentProjectsReturnsEmptyListInitially() { + List projects = service.getRecentProjects(); + assertNotNull(projects); + assertTrue(projects.isEmpty()); + } + + @Test + void addRecentProjectStoresProject() throws IOException { + stubWriteFile(); + + service.addRecentProject("MyProject", "/path/to/project"); + + List projects = service.getRecentProjects(); + assertEquals(1, projects.size()); + assertEquals("MyProject", projects.getFirst().name()); + } + + @Test + void addRecentProjectMovesExistingToFront() throws IOException { + stubWriteFile(); + + service.addRecentProject("First", "/path/first"); + service.addRecentProject("Second", "/path/second"); + service.addRecentProject("First", "/path/first"); + + List projects = service.getRecentProjects(); + assertEquals(2, projects.size()); + assertEquals("First", projects.getFirst().name()); + assertEquals("Second", projects.get(1).name()); + } + + @Test + void addRecentProjectLimitsToMaxSize() throws IOException { + stubWriteFile(); + + for (int i = 0; i < 15; i++) { + service.addRecentProject("Project" + i, "/path/project" + i); + } + + List projects = service.getRecentProjects(); + assertEquals(10, projects.size()); + assertEquals("Project14", projects.getFirst().name()); + } + + @Test + void removeRecentProjectDeletesEntry() throws IOException { + stubWriteFile(); + + service.addRecentProject("ToRemove", "/path/to-remove"); + service.addRecentProject("ToKeep", "/path/to-keep"); + + service.removeRecentProject("/path/to-remove"); + + List projects = service.getRecentProjects(); + assertEquals(1, projects.size()); + assertEquals("ToKeep", projects.getFirst().name()); + } + + @Test + void removeRecentProjectIgnoresNonExistent() throws IOException { + stubWriteFile(); + + service.addRecentProject("Existing", "/path/existing"); + + service.removeRecentProject("/path/nonexistent"); + + List projects = service.getRecentProjects(); + assertEquals(1, projects.size()); + } + + @Test + void addRecentProjectIgnoresNullName() { + service.addRecentProject(null, "/path"); + + List projects = service.getRecentProjects(); + assertTrue(projects.isEmpty()); + } + + @Test + void addRecentProjectIgnoresBlankName() { + service.addRecentProject(" ", "/path"); + + List projects = service.getRecentProjects(); + assertTrue(projects.isEmpty()); + } + + @Test + void addRecentProjectIgnoresNullPath() { + service.addRecentProject("Name", null); + + List projects = service.getRecentProjects(); + assertTrue(projects.isEmpty()); + } + + @Test + void addRecentProjectIgnoresBlankPath() { + service.addRecentProject("Name", " "); + + List projects = service.getRecentProjects(); + assertTrue(projects.isEmpty()); + } + + @Test + void removeRecentProjectIgnoresNull() throws IOException { + stubWriteFile(); + + service.addRecentProject("Existing", "/path/existing"); + + service.removeRecentProject(null); + + assertEquals(1, service.getRecentProjects().size()); + } + + @Test + void removeRecentProjectIgnoresBlank() throws IOException { + stubWriteFile(); + + service.addRecentProject("Existing", "/path/existing"); + + service.removeRecentProject(" "); + + assertEquals(1, service.getRecentProjects().size()); + } + + @Test + void addRecentProjectSetsLastOpenedTimestamp() throws IOException { + stubWriteFile(); + + service.addRecentProject("Project", "/path/project"); + + List projects = service.getRecentProjects(); + assertNotNull(projects.getFirst().lastOpened()); + assertFalse(projects.getFirst().lastOpened().isEmpty()); + } + + @Test + void getRecentProjectsHandlesCorruptJson() { + storedJson = "not valid json"; + + List projects = service.getRecentProjects(); + assertNotNull(projects); + assertTrue(projects.isEmpty()); + } + + @Test + void addRecentProjectSavesToStorage() throws IOException { + stubWriteFile(); + + service.addRecentProject("Project", "/path/project"); + + verify(fileSystemStorage).writeFile(eq("recent-projects.json"), anyString()); + } + + @Test + void removeRecentProjectSavesToStorage() throws IOException { + stubWriteFile(); + + service.addRecentProject("Project", "/path/project"); + reset(fileSystemStorage); + when(fileSystemStorage.isLocalEnvironment()).thenReturn(false); + when(fileSystemStorage.readFile(anyString())).thenAnswer(inv -> storedJson); + doAnswer(inv -> { + storedJson = inv.getArgument(1); + return null; + }) + .when(fileSystemStorage) + .writeFile(anyString(), anyString()); + + service.removeRecentProject("/path/project"); + + verify(fileSystemStorage).writeFile(eq("recent-projects.json"), anyString()); + } +} diff --git a/src/test/java/org/frankframework/flow/security/UserContextFilterTest.java b/src/test/java/org/frankframework/flow/security/UserContextFilterTest.java new file mode 100644 index 00000000..d41ae5a9 --- /dev/null +++ b/src/test/java/org/frankframework/flow/security/UserContextFilterTest.java @@ -0,0 +1,188 @@ +package org.frankframework.flow.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class UserContextFilterTest { + + private UserContextFilter filter; + private UserWorkspaceContext userWorkspaceContext; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + private FilterChain filterChain; + + @BeforeEach + void setUp() { + userWorkspaceContext = new UserWorkspaceContext(); + filter = new UserContextFilter(userWorkspaceContext, objectMapper); + } + + @Test + void extractsUserIdFromJwtSubClaim() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + String payload = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("{\"sub\":\"user123\"}".getBytes(StandardCharsets.UTF_8)); + String jwt = "header." + payload + ".signature"; + request.addHeader("Authorization", "Bearer " + jwt); + + filter.doFilter(request, response, filterChain); + + assertTrue(userWorkspaceContext.isInitialized()); + assertEquals("user123", userWorkspaceContext.getWorkspaceId()); + verify(filterChain).doFilter(request, response); + } + + @Test + void extractsUserIdFromJwtPreferredUsernameClaim() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + String payload = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("{\"preferred_username\":\"jdoe\"}".getBytes(StandardCharsets.UTF_8)); + String jwt = "header." + payload + ".signature"; + request.addHeader("Authorization", "Bearer " + jwt); + + filter.doFilter(request, response, filterChain); + + assertTrue(userWorkspaceContext.isInitialized()); + assertEquals("jdoe", userWorkspaceContext.getWorkspaceId()); + } + + @Test + void usesSessionIdWhenNoJwt() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + request.addHeader("X-Session-ID", "my-session-123"); + + filter.doFilter(request, response, filterChain); + + assertTrue(userWorkspaceContext.isInitialized()); + assertTrue(userWorkspaceContext.getWorkspaceId().startsWith("anon-")); + assertEquals(5 + 16, userWorkspaceContext.getWorkspaceId().length()); // "anon-" + 16 hex chars + } + + @Test + void defaultsToAnonymousWithoutHeaders() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, filterChain); + + assertTrue(userWorkspaceContext.isInitialized()); + assertEquals("anonymous", userWorkspaceContext.getWorkspaceId()); + } + + @Test + void skipsInitializationWhenAlreadyInitialized() throws IOException, ServletException { + userWorkspaceContext.initialize("pre-set-id"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Authorization", "Bearer invalid"); + + filter.doFilter(request, response, filterChain); + + assertEquals("pre-set-id", userWorkspaceContext.getWorkspaceId()); + } + + @Test + void handlesInvalidJwtGracefully() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Authorization", "Bearer not-a-valid-jwt"); + + filter.doFilter(request, response, filterChain); + + assertTrue(userWorkspaceContext.isInitialized()); + assertEquals("anonymous", userWorkspaceContext.getWorkspaceId()); + } + + @Test + void sanitizesSpecialCharactersInUserId() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + String payload = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("{\"sub\":\"user!@#$%^&\"}".getBytes(StandardCharsets.UTF_8)); + String jwt = "header." + payload + ".signature"; + request.addHeader("Authorization", "Bearer " + jwt); + + filter.doFilter(request, response, filterChain); + + String workspaceId = userWorkspaceContext.getWorkspaceId(); + assertTrue(workspaceId.matches("[a-zA-Z0-9.@_-]+")); + } + + @Test + void handlesBlankSessionId() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("X-Session-ID", " "); + + filter.doFilter(request, response, filterChain); + + assertEquals("anonymous", userWorkspaceContext.getWorkspaceId()); + } + + @Test + void handlesJwtWithOnlyOnePartGracefully() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + request.addHeader("Authorization", "Bearer singlepart"); + + filter.doFilter(request, response, filterChain); + + assertEquals("anonymous", userWorkspaceContext.getWorkspaceId()); + } + + @Test + void chainIsAlwaysCalled() throws IOException, ServletException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + } + + @Test + void consistentSessionHashing() throws IOException, ServletException { + MockHttpServletRequest request1 = new MockHttpServletRequest(); + MockHttpServletResponse response1 = new MockHttpServletResponse(); + request1.addHeader("X-Session-ID", "same-session"); + filter.doFilter(request1, response1, filterChain); + String firstId = userWorkspaceContext.getWorkspaceId(); + + // Create a fresh context for the second request + UserWorkspaceContext ctx2 = new UserWorkspaceContext(); + UserContextFilter filter2 = new UserContextFilter(ctx2, objectMapper); + MockHttpServletRequest request2 = new MockHttpServletRequest(); + MockHttpServletResponse response2 = new MockHttpServletResponse(); + request2.addHeader("X-Session-ID", "same-session"); + filter2.doFilter(request2, response2, filterChain); + + assertEquals(firstId, ctx2.getWorkspaceId()); + } +}