From 70ac2225ba3abad2d1729fe9f91a0f1e7eb2f077 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 12:07:42 +0200 Subject: [PATCH 1/5] Add /build to .gitignore to exclude build artifacts --- src/main/frontend/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/frontend/.gitignore b/src/main/frontend/.gitignore index c65a0878..fc091fc6 100644 --- a/src/main/frontend/.gitignore +++ b/src/main/frontend/.gitignore @@ -9,3 +9,5 @@ /.env /.env.production /.env.development + +/build From 3355b90b72008f0c8b82bc1f57405c01c160840b Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 12:52:28 +0200 Subject: [PATCH 2/5] Update development environment configuration and enhance security settings --- src/main/frontend/environment/development.ts | 2 +- src/main/frontend/vite.config.ts | 3 ++ .../flow/common/config/CsrfCookieFilter.java | 32 +++++++++++++++++++ .../config/SecurityChainConfigurer.java | 6 ++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/frankframework/flow/common/config/CsrfCookieFilter.java diff --git a/src/main/frontend/environment/development.ts b/src/main/frontend/environment/development.ts index 50d445be..9796d233 100644 --- a/src/main/frontend/environment/development.ts +++ b/src/main/frontend/environment/development.ts @@ -2,7 +2,7 @@ import base, { type EnvironmentVariables } from './base' const development: EnvironmentVariables = { ...base, - apiBaseUrl: 'http://localhost:8080', + apiBaseUrl: '', } export default development diff --git a/src/main/frontend/vite.config.ts b/src/main/frontend/vite.config.ts index 80d9c0f3..6260d8f4 100644 --- a/src/main/frontend/vite.config.ts +++ b/src/main/frontend/vite.config.ts @@ -28,5 +28,8 @@ export default defineConfig({ }, server: { port: 3000, + proxy: { + '/api': 'http://localhost:8080', + }, }, }) diff --git a/src/main/java/org/frankframework/flow/common/config/CsrfCookieFilter.java b/src/main/java/org/frankframework/flow/common/config/CsrfCookieFilter.java new file mode 100644 index 00000000..78758233 --- /dev/null +++ b/src/main/java/org/frankframework/flow/common/config/CsrfCookieFilter.java @@ -0,0 +1,32 @@ +package org.frankframework.flow.common.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.jspecify.annotations.NonNull; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Forces the deferred {@link CsrfToken} to load on every request so the XSRF-TOKEN cookie is + * written. Otherwise the cookie is only minted when something reads the token, leaving a SPA with no + * token to echo back as X-XSRF-TOKEN. + */ +public class CsrfCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken != null) { + csrfToken.getToken(); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java b/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java index d1084c00..87659b4c 100644 --- a/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java +++ b/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java @@ -23,6 +23,7 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.util.matcher.AnyRequestMatcher; @Configuration @@ -61,6 +62,11 @@ public SecurityFilterChain configureChain(IAuthenticator authenticator, HttpSecu http.formLogin(FormLoginConfigurer::disable); http.logout(LogoutConfigurer::disable); http.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); + + if (csrfEnabled) { + http.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class); + } + return authenticator.configureHttpSecurity(http); } From 669cf901c35c7e204e273b0fef49ac9c00a90254 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 13:24:06 +0200 Subject: [PATCH 3/5] Refactor API URL handling and improve CSRF configuration --- src/main/frontend/.gitignore | 2 -- src/main/frontend/app/utils/api.ts | 4 +-- src/main/frontend/environment/base.ts | 9 ------ src/main/frontend/environment/development.ts | 8 ----- src/main/frontend/environment/environment.ts | 9 ------ src/main/frontend/environment/production.ts | 7 ----- .../config/SecurityChainConfigurer.java | 29 ++++++++++--------- 7 files changed, 17 insertions(+), 51 deletions(-) delete mode 100644 src/main/frontend/environment/base.ts delete mode 100644 src/main/frontend/environment/development.ts delete mode 100644 src/main/frontend/environment/environment.ts delete mode 100644 src/main/frontend/environment/production.ts diff --git a/src/main/frontend/.gitignore b/src/main/frontend/.gitignore index fc091fc6..c65a0878 100644 --- a/src/main/frontend/.gitignore +++ b/src/main/frontend/.gitignore @@ -9,5 +9,3 @@ /.env /.env.production /.env.development - -/build diff --git a/src/main/frontend/app/utils/api.ts b/src/main/frontend/app/utils/api.ts index 3279a424..888e5994 100644 --- a/src/main/frontend/app/utils/api.ts +++ b/src/main/frontend/app/utils/api.ts @@ -1,7 +1,5 @@ -import variables from '../../environment/environment' - export function apiUrl(path: string): string { - return `${variables.apiBaseUrl}/api${path}` + return `/api${path}` } const getAnonymousSessionId = () => { diff --git a/src/main/frontend/environment/base.ts b/src/main/frontend/environment/base.ts deleted file mode 100644 index 299efc10..00000000 --- a/src/main/frontend/environment/base.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface EnvironmentVariables { - apiBaseUrl: string -} - -const base: EnvironmentVariables = { - apiBaseUrl: '', -} - -export default base diff --git a/src/main/frontend/environment/development.ts b/src/main/frontend/environment/development.ts deleted file mode 100644 index 9796d233..00000000 --- a/src/main/frontend/environment/development.ts +++ /dev/null @@ -1,8 +0,0 @@ -import base, { type EnvironmentVariables } from './base' - -const development: EnvironmentVariables = { - ...base, - apiBaseUrl: '', -} - -export default development diff --git a/src/main/frontend/environment/environment.ts b/src/main/frontend/environment/environment.ts deleted file mode 100644 index e42c2d24..00000000 --- a/src/main/frontend/environment/environment.ts +++ /dev/null @@ -1,9 +0,0 @@ -import development from './development' -import production from './production' -import type { EnvironmentVariables } from './base' - -const environment = process.env.NODE_ENV - -const variables: EnvironmentVariables = environment === 'development' ? development : production - -export default variables diff --git a/src/main/frontend/environment/production.ts b/src/main/frontend/environment/production.ts deleted file mode 100644 index 71565e0a..00000000 --- a/src/main/frontend/environment/production.ts +++ /dev/null @@ -1,7 +0,0 @@ -import base, { type EnvironmentVariables } from './base' - -const production: EnvironmentVariables = { - ...base, -} - -export default production diff --git a/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java b/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java index 87659b4c..96d1f8db 100644 --- a/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java +++ b/src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java @@ -17,6 +17,7 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; @@ -50,23 +51,11 @@ public void setEnvironment(Environment environment) { public SecurityFilterChain configureChain(IAuthenticator authenticator, HttpSecurity http) throws Exception { configureAuthenticator(authenticator); http.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); - http.csrf(csrf -> { - if (csrfEnabled) { - csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); - csrf.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()); - return; - } - csrf.disable(); - }); + configureCsrf(http); http.securityMatcher(AnyRequestMatcher.INSTANCE); http.formLogin(FormLoginConfigurer::disable); http.logout(LogoutConfigurer::disable); http.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); - - if (csrfEnabled) { - http.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class); - } - return authenticator.configureHttpSecurity(http); } @@ -81,4 +70,18 @@ private void configureAuthenticator(IAuthenticator authenticator) { servletConfig.setUrlMapping("/*"); authenticator.registerServlet(servletConfig); } + + private void configureCsrf(HttpSecurity http) { + if (!csrfEnabled) { + http.csrf(AbstractHttpConfigurer::disable); + return; + } + + http.csrf(csrf -> { + csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); + csrf.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()); + }); + + http.addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class); + } } From 4aea0437a65ddfaa578f2942d0e08ca33b48ae93 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 15:20:23 +0200 Subject: [PATCH 4/5] Refactor adapter and config xml creation and configuration file handling to improve path normalization and response types --- .../file-structure/editor-file-structure.tsx | 17 +++++++++++ .../use-file-tree-context-menu.ts | 22 +++++++++++++-- .../file-structure/use-studio-context-menu.ts | 26 +++++++++++------ .../add-configuration-modal.tsx | 4 ++- .../frontend/app/services/adapter-service.ts | 4 +-- .../services/configuration-file-service.ts | 4 +-- src/main/frontend/app/utils/path-utils.ts | 4 +-- .../flow/adapter/AdapterController.java | 6 ++-- .../flow/adapter/AdapterService.java | 6 ++-- .../ConfigurationController.java | 4 +-- .../configuration/ConfigurationService.java | 28 +++++++++---------- .../flow/file/FileTreeService.java | 2 +- .../project/ConfigurationProjectService.java | 4 +-- .../ConfigurationControllerTest.java | 8 ++++-- .../ConfigurationServiceTest.java | 25 +++++++++++------ 15 files changed, 109 insertions(+), 55 deletions(-) diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index 9e900128..1711a2c4 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -114,9 +114,26 @@ export default function EditorFileStructure() { [getTab, removeTabAndSelectFallback], ) + const configurationsRootPath = useMemo(() => { + const paths = project?.filepaths + if (!paths?.length) return + + // each path → its directory segments (drop the filename) + const segments = paths.map((p) => p.replaceAll('\\', '/').split('/').slice(0, -1)) + + const common = segments.reduce((a, b) => { + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return a.slice(0, i) + }) + + return common.length > 0 ? common.join('/') : undefined + }, [project?.filepaths]) + const editorContextMenu = useFileTreeContextMenu({ projectName: project?.name, dataProvider, + configurationsRootPath, onAfterRename, onAfterDelete, }) diff --git a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts index 842b15d2..cdac6a5b 100644 --- a/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-file-tree-context-menu.ts @@ -1,13 +1,15 @@ import React, { useCallback, useRef, useState } from 'react' +import { useNavigate } from 'react-router' import type { TreeItemIndex } from 'react-complex-tree' import { createFile, deleteFile, renameFile } from '~/services/file-service' import { createFolderInProject } from '~/services/file-tree-service' -import { clearConfigurationFileCache } from '~/services/configuration-file-service' +import { clearConfigurationFileCache, createConfigurationFile } from '~/services/configuration-file-service' import useTabStore from '~/stores/tab-store' import useEditorTabStore from '~/stores/editor-tab-store' import { showErrorToast } from '~/components/toast' import { FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS } from '~/components/file-structure/name-input-dialog' import { logApiError } from '~/utils/logger' +import { openInEditor } from '~/actions/navigationActions' export interface ContextMenuState { position: { x: number; y: number } @@ -43,6 +45,7 @@ export interface DataProviderLike { interface UseFileTreeContextMenuOptions { projectName: string | undefined dataProvider: DataProviderLike | null + configurationsRootPath?: string onAfterRename?: (oldPath: string, newName: string) => void onAfterDelete?: (path: string) => void } @@ -70,9 +73,11 @@ function buildNewPath(oldPath: string, newName: string): string { export function useFileTreeContextMenu({ projectName, dataProvider, + configurationsRootPath, onAfterRename, onAfterDelete, }: UseFileTreeContextMenuOptions) { + const navigate = useNavigate() const [contextMenu, setContextMenu] = useState(null) const [nameDialog, setNameDialog] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) @@ -127,9 +132,20 @@ export function useFileTreeContextMenu({ return } + const filePath = `${parentPath}/${name}` + const isXml = name.toLowerCase().endsWith('.xml') + const configsRoot = configurationsRootPath?.replaceAll('\\', '/') + const normalizedParent = parentPath.replaceAll('\\', '/') + const isInsideConfigurations = !!configsRoot && normalizedParent.startsWith(configsRoot) + try { - await createFile(projectName, `${parentPath}/${name}`) + await (isXml && isInsideConfigurations + ? createConfigurationFile(projectName, filePath) + : createFile(projectName, filePath)) + await dataProvider.reloadDirectory(parentItemId) + + openInEditor(name, filePath, navigate) } catch (error) { logApiError('Failed to create file', error as Error) } @@ -138,7 +154,7 @@ export function useFileTreeContextMenu({ patterns: FILE_NAME_PATTERNS, }) }, - [projectName, dataProvider, closeContextMenu], + [projectName, dataProvider, configurationsRootPath, navigate, closeContextMenu], ) const handleNewFolder = useCallback( diff --git a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts index 64273cd0..de9206de 100644 --- a/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts +++ b/src/main/frontend/app/components/file-structure/use-studio-context-menu.ts @@ -1,4 +1,5 @@ import React, { useCallback, useRef, useState } from 'react' +import { useNavigate } from 'react-router' import type { TreeItemIndex } from 'react-complex-tree' import { deleteFile, renameFile } from '~/services/file-service' import { createFolderInProject } from '~/services/file-tree-service' @@ -12,6 +13,8 @@ import { FILE_NAME_PATTERNS, FOLDER_OR_ADAPTER_NAME_PATTERNS, } from '~/components/file-structure/name-input-dialog' +import { openInStudio } from '~/actions/navigationActions' +import { findAdaptersInXml } from '~/routes/editor/xml-utils' export type StudioItemType = 'root' | 'folder' | 'configuration' | 'adapter' | 'file' @@ -123,6 +126,7 @@ function getRenamePatterns(itemType: StudioItemType): Record { } export function useStudioContextMenu({ projectName, dataProvider }: UseStudioContextMenuOptions) { + const navigate = useNavigate() const [contextMenu, setContextMenu] = useState(null) const [nameDialog, setNameDialog] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) @@ -176,14 +180,12 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon onSubmit: async (name: string) => { const fileName = ensureXmlExtension(name) try { - const rootPath = dataProvider.getRootPath().replace(/[/\\]$/, '') const folderPath = menu.folderPath.replace(/[/\\]$/, '') - const relativePath = - folderPath === rootPath - ? fileName - : `${folderPath.slice(rootPath.length + 1).replaceAll('\\', '/')}/${fileName}` - await createConfigurationFile(projectName, relativePath) + const absoluteFilePath = `${folderPath}/${fileName}` + await createConfigurationFile(projectName, absoluteFilePath) await dataProvider.reloadDirectory('root') + + openInStudio(navigate, { adapterName: 'SampleAdapter', filepath: absoluteFilePath, adapterPosition: 0 }) } catch (error) { logApiError('Failed to create configuration', error as Error) } @@ -192,7 +194,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon patterns: CONFIGURATION_NAME_PATTERNS, }) }, - [projectName, dataProvider, closeContextMenu], + [projectName, dataProvider, navigate, closeContextMenu], ) const handleNewAdapter = useCallback( @@ -206,8 +208,14 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon submitLabel: 'Create', onSubmit: async (name: string) => { try { - await createAdapter(projectName, name, menu.path) + const { xmlContent } = await createAdapter(projectName, name, menu.path) await dataProvider.reloadDirectory('root') + + const adapterIndex = findAdaptersInXml(xmlContent).findIndex((adapter) => adapter.name === name) + + if (adapterIndex !== -1) { + openInStudio(navigate, { adapterName: name, filepath: menu.path, adapterPosition: adapterIndex }) + } } catch (error) { logApiError('Failed to create adapter', error as Error) } @@ -216,7 +224,7 @@ export function useStudioContextMenu({ projectName, dataProvider }: UseStudioCon patterns: FOLDER_OR_ADAPTER_NAME_PATTERNS, }) }, - [projectName, dataProvider, closeContextMenu], + [projectName, dataProvider, navigate, closeContextMenu], ) const handleNewFolder = useCallback( 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 630bcc2f..43ba04df 100644 --- a/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx +++ b/src/main/frontend/app/routes/configurations/add-configuration-modal.tsx @@ -52,7 +52,9 @@ export default function AddConfigurationModal({ configname = `${configname}.xml` } - await createConfigurationFile(currentConfiguration.name, `${rootLocationName}/${configname}`) + const folderPath = rootLocationName.replace(/[/\\]$/, '') + const absoluteFilePath = `${folderPath}/${configname}` + await createConfigurationFile(currentConfiguration.name, absoluteFilePath) const updatedProject = await fetchProject(currentConfiguration.name) setProject(updatedProject) onSuccess?.() diff --git a/src/main/frontend/app/services/adapter-service.ts b/src/main/frontend/app/services/adapter-service.ts index dc2129ea..ad271ff9 100644 --- a/src/main/frontend/app/services/adapter-service.ts +++ b/src/main/frontend/app/services/adapter-service.ts @@ -30,8 +30,8 @@ export async function createAdapter( projectName: string, adapterName: string, configurationPath: string, -): Promise { - await apiFetch(`/projects/${encodeURIComponent(projectName)}/adapters`, { +): Promise { + return apiFetch(`/projects/${encodeURIComponent(projectName)}/adapters`, { method: 'POST', body: JSON.stringify({ adapterName, configurationPath }), }) diff --git a/src/main/frontend/app/services/configuration-file-service.ts b/src/main/frontend/app/services/configuration-file-service.ts index b972e99c..57df65a0 100644 --- a/src/main/frontend/app/services/configuration-file-service.ts +++ b/src/main/frontend/app/services/configuration-file-service.ts @@ -48,8 +48,8 @@ export async function saveConfigurationFile( }) } -export async function createConfigurationFile(projectName: string, filename: string): Promise { - return apiFetch(`${getBaseUrl(projectName)}?name=${encodeURIComponent(filename)}`, { method: 'POST' }) +export async function createConfigurationFile(projectName: string, filepath: string): Promise { + return apiFetch(`${getBaseUrl(projectName)}?path=${encodeURIComponent(filepath)}`, { method: 'POST' }) } function getBaseUrl(projectName: string): string { diff --git a/src/main/frontend/app/utils/path-utils.ts b/src/main/frontend/app/utils/path-utils.ts index af12ef87..de4cce56 100644 --- a/src/main/frontend/app/utils/path-utils.ts +++ b/src/main/frontend/app/utils/path-utils.ts @@ -5,8 +5,8 @@ export function toRelativePath(absolutePath: string, marker: string): string | null { const normalizedPath = normalizePath(absolutePath) const normalizedMarker = normalizePath(marker) - const idx = normalizedPath.indexOf(marker) - return idx === -1 ? null : normalizedMarker.slice(idx + marker.length) + const idx = normalizedPath.indexOf(normalizedMarker) + return idx === -1 ? null : normalizedPath.slice(idx + normalizedMarker.length) } export function normalizePath(path: string) { diff --git a/src/main/java/org/frankframework/flow/adapter/AdapterController.java b/src/main/java/org/frankframework/flow/adapter/AdapterController.java index 729d7f92..91791109 100644 --- a/src/main/java/org/frankframework/flow/adapter/AdapterController.java +++ b/src/main/java/org/frankframework/flow/adapter/AdapterController.java @@ -44,9 +44,9 @@ public ResponseEntity updateAdapter(@RequestBody AdapterUpdateDTO dto) thr } @PostMapping("/{projectName}/adapters") - public ResponseEntity createAdapter(@PathVariable String projectName, @RequestBody AdapterCreateDTO dto) throws IOException { - adapterService.createAdapter(dto.configurationPath(), dto.adapterName()); - return ResponseEntity.ok().build(); + public ResponseEntity createAdapter(@PathVariable String projectName, @RequestBody AdapterCreateDTO dto) throws IOException { + String content = adapterService.createAdapter(dto.configurationPath(), dto.adapterName()); + return ResponseEntity.ok(new ConfigurationXmlDTO(content)); } @PatchMapping("/{projectName}/adapters/rename") diff --git a/src/main/java/org/frankframework/flow/adapter/AdapterService.java b/src/main/java/org/frankframework/flow/adapter/AdapterService.java index 974e2612..0e30fc6d 100644 --- a/src/main/java/org/frankframework/flow/adapter/AdapterService.java +++ b/src/main/java/org/frankframework/flow/adapter/AdapterService.java @@ -41,8 +41,9 @@ public ConfigurationXmlDTO getAdapter(String projectName, String configurationPa throws IOException, ApiException, SAXException, ParserConfigurationException, TransformerException { ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); + String normalizedConfigPath = configurationPath.replace("\\", "/"); ConfigurationFile config = configurationProject.getConfigurationFiles().stream() - .filter(configurationFile -> configurationFile.getFilepath().equals(configurationPath)) + .filter(configurationFile -> configurationFile.getFilepath().replace("\\", "/").equals(normalizedConfigPath)) .findFirst() .orElseThrow(() -> new ApiException(String.format("Configuration File with path: %s not found", configurationPath), HttpStatus.NOT_FOUND)); @@ -84,7 +85,7 @@ public boolean updateAdapter(Path configurationFile, String adapterName, String } } - public void createAdapter(String configurationPath, String adapterName) throws IOException { + public String createAdapter(String configurationPath, String adapterName) throws IOException { if (configurationPath == null || configurationPath.isBlank()) { throw new IllegalArgumentException("Configuration path must not be empty"); } @@ -107,6 +108,7 @@ public void createAdapter(String configurationPath, String adapterName) throws I String updatedXml = XmlConfigurationUtils.convertNodeToString(configDoc); Files.writeString(absConfigFile, updatedXml, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); + return updatedXml; } catch (Exception exception) { throw new IOException("Failed to create adapter: " + exception.getMessage(), exception); } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java index a443b6a0..ebc4e15b 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationController.java @@ -51,9 +51,9 @@ public ResponseEntity updateConfiguration( @PostMapping() public ResponseEntity addConfiguration( @PathVariable String projectName, - @RequestParam String name + @RequestParam String path ) throws ApiException, IOException, TransformerException, ParserConfigurationException, SAXException { - String content = configurationService.addConfiguration(projectName, name); + String content = configurationService.addConfiguration(projectName, path); ConfigurationXmlDTO configurationXmlDTO = new ConfigurationXmlDTO(content); return ResponseEntity.ok(configurationXmlDTO); } diff --git a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java index b19638cb..a912afea 100644 --- a/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java +++ b/src/main/java/org/frankframework/flow/configuration/ConfigurationService.java @@ -30,8 +30,6 @@ @Service public class ConfigurationService { - private static final String CONFIGURATIONS_DIR = "src/main/configurations"; - private final FileSystemStorage fileSystemStorage; private final ConfigurationProjectService configurationProjectService; private final FileTreeService fileTreeService; @@ -94,26 +92,28 @@ public String updateConfiguration(String projectName, String filepath, String co } } - public String addConfiguration(String projectName, String configurationName) throws IOException, ApiException, TransformerException, ParserConfigurationException, SAXException { - ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); - Path absProjectPath = fileSystemStorage.toAbsolutePath(configurationProject.getRootPath()); - Path configDir = absProjectPath.resolve(CONFIGURATIONS_DIR).normalize(); - - if (!Files.exists(configDir)) { - Files.createDirectories(configDir); + public String addConfiguration(String projectName, String filepath) throws IOException, ApiException, TransformerException, ParserConfigurationException, SAXException { + if (filepath == null || filepath.isBlank()) { + throw new ApiException("Configuration path must not be empty", HttpStatus.BAD_REQUEST); } + if (filepath.contains("..")) { + throw new ApiException("Invalid configuration path: " + filepath, HttpStatus.BAD_REQUEST); + } + + ConfigurationProject configurationProject = configurationProjectService.getProject(projectName); + Path projectRoot = fileSystemStorage.toAbsolutePath(configurationProject.getRootPath()).normalize(); + Path absoluteFilePath = fileSystemStorage.toAbsolutePath(filepath).normalize(); - Path filePath = configDir.resolve(configurationName).normalize(); - if (!filePath.startsWith(configDir)) { - throw new ApiException("Invalid configuration name: " + configurationName, HttpStatus.BAD_REQUEST); + if (!absoluteFilePath.startsWith(projectRoot)) { + throw new ApiException("Invalid configuration path: " + filepath, HttpStatus.BAD_REQUEST); } - Files.createDirectories(filePath.getParent()); + Files.createDirectories(absoluteFilePath.getParent()); String defaultXml = loadDefaultConfigurationXml(); Document updatedDocument = XmlConfigurationUtils.insertFlowNamespace(defaultXml); String updatedContent = XmlConfigurationUtils.convertNodeToString(updatedDocument); - fileSystemStorage.writeFile(filePath.toString(), updatedContent); + fileSystemStorage.writeFile(absoluteFilePath.toString(), updatedContent); fileTreeService.invalidateTreeCache(projectName); return updatedContent; } diff --git a/src/main/java/org/frankframework/flow/file/FileTreeService.java b/src/main/java/org/frankframework/flow/file/FileTreeService.java index 3edea9ce..50310c83 100644 --- a/src/main/java/org/frankframework/flow/file/FileTreeService.java +++ b/src/main/java/org/frankframework/flow/file/FileTreeService.java @@ -183,7 +183,7 @@ private List extractAdapterNames(Path xmlFile) { private String toNodePath(Path path, Path relativizeRoot, boolean useRelativePaths) { if (!useRelativePaths) { - return path.toAbsolutePath().toString(); + return path.toAbsolutePath().toString().replace("\\", "/"); } String relativePath = relativizeRoot.relativize(path).toString().replace("\\", "/"); return relativePath.isEmpty() ? "." : relativePath; diff --git a/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java b/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java index 6369cda3..7fd52f1f 100644 --- a/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java +++ b/src/main/java/org/frankframework/flow/project/ConfigurationProjectService.java @@ -234,7 +234,7 @@ public ConfigurationProject importProjectFromFiles(String projectName, List filepaths = getConfigurationFilesDynamically(configurationProject.getRootPath()); @@ -265,7 +265,7 @@ private List getConfigurationFilesDynamically(String projectRoot) { try (Stream stream = Files.walk(absolutePath)) { return stream.filter(Files::isRegularFile) .filter(path -> path.toString().toLowerCase().endsWith(".xml")) - .map(path -> fileSystemStorage.toRelativePath(path.toString())) + .map(path -> fileSystemStorage.toRelativePath(path.toString()).replace("\\", "/")) .toList(); } } catch (IOException exception) { diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java index 9248a2ff..28c6dcf5 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationControllerTest.java @@ -136,7 +136,9 @@ void addConfigurationReturnsDefaultContent() throws Exception { when(settings.getFilters()).thenReturn(Map.of(FilterType.ADAPTER, true)); when(configurationProject.getConfigurationSettings()).thenReturn(settings); - when(configurationService.addConfiguration(TEST_PROJECT_NAME, "NewConfig.xml")) + String filepath = "/path/to/" + TEST_PROJECT_NAME + "/NewConfig.xml"; + + when(configurationService.addConfiguration(TEST_PROJECT_NAME, filepath)) .thenReturn(""); when(configurationProjectService.toDto(configurationProject)) .thenReturn(new ConfigurationProjectDTO( @@ -148,11 +150,11 @@ void addConfigurationReturnsDefaultContent() throws Exception { false )); - mockMvc.perform(post("/api/projects/" + TEST_PROJECT_NAME + "/configuration?name=NewConfig.xml") + mockMvc.perform(post("/api/projects/" + TEST_PROJECT_NAME + "/configuration?path=" + filepath) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.xmlContent").value("")); - verify(configurationService).addConfiguration(TEST_PROJECT_NAME, "NewConfig.xml"); + verify(configurationService).addConfiguration(TEST_PROJECT_NAME, filepath); } } diff --git a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java index f8609a17..1dcd9102 100644 --- a/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java +++ b/src/test/java/org/frankframework/flow/configuration/ConfigurationServiceTest.java @@ -259,12 +259,11 @@ void addConfiguration_Success() throws Exception { when(configurationProjectService.getProject("myproject")).thenReturn(configurationProject); - String result = configurationService.addConfiguration("myproject", "NewConfig.xml"); + Path target = projectDir.resolve("src/main/configurations/NewConfig.xml"); + String result = configurationService.addConfiguration("myproject", target.toString()); assertNotNull(result); - - Path expectedFile = projectDir.resolve("src/main/configurations/NewConfig.xml"); - assertTrue(Files.exists(expectedFile), "NewConfig.xml should be created on disk"); + assertTrue(Files.exists(target), "NewConfig.xml should be created on disk"); verify(fileTreeService).invalidateTreeCache("myproject"); } @@ -278,10 +277,10 @@ void addConfiguration_CreatesNestedDirectories() throws Exception { ConfigurationProject configurationProject = new ConfigurationProject("myproject", projectDir.toString()); when(configurationProjectService.getProject("myproject")).thenReturn(configurationProject); - configurationService.addConfiguration("myproject", "subfolder/NestedConfig.xml"); + Path target = projectDir.resolve("subfolder/NestedConfig.xml"); + configurationService.addConfiguration("myproject", target.toString()); - Path expectedFile = projectDir.resolve("src/main/configurations/subfolder/NestedConfig.xml"); - assertTrue(Files.exists(expectedFile)); + assertTrue(Files.exists(target)); } @Test @@ -291,7 +290,14 @@ void addConfiguration_ProjectNotFound_ThrowsException() throws ApiException { } @Test - void addConfiguration_PathTraversal_ThrowsSecurityException() throws Exception { + void addConfiguration_PathTraversal_ThrowsException() throws Exception { + ApiException exception = assertThrows( + ApiException.class, () -> configurationService.addConfiguration("myproject", "../../../evil.xml")); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + } + + @Test + void addConfiguration_OutsideProject_ThrowsException() throws Exception { stubToAbsolutePath(); Path projectDir = tempDir.resolve("myproject"); @@ -299,8 +305,9 @@ void addConfiguration_PathTraversal_ThrowsSecurityException() throws Exception { ConfigurationProject configurationProject = new ConfigurationProject("myproject", projectDir.toString()); when(configurationProjectService.getProject("myproject")).thenReturn(configurationProject); + String outsidePath = tempDir.resolve("other/evil.xml").toString(); ApiException exception = assertThrows( - ApiException.class, () -> configurationService.addConfiguration("myproject", "../../../evil.xml")); + ApiException.class, () -> configurationService.addConfiguration("myproject", outsidePath)); assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); } From 3cc63248a9c76d1c86928cf381ec85fbc007be03 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Mon, 15 Jun 2026 15:28:10 +0200 Subject: [PATCH 5/5] Refactor path handling in editor file structure to improve readability --- .../app/components/file-structure/editor-file-structure.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx index 1711a2c4..4f9392f0 100644 --- a/src/main/frontend/app/components/file-structure/editor-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/editor-file-structure.tsx @@ -118,8 +118,7 @@ export default function EditorFileStructure() { const paths = project?.filepaths if (!paths?.length) return - // each path → its directory segments (drop the filename) - const segments = paths.map((p) => p.replaceAll('\\', '/').split('/').slice(0, -1)) + const segments = paths.map((path) => path.replaceAll('\\', '/').split('/').slice(0, -1)) const common = segments.reduce((a, b) => { let i = 0