Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 22 additions & 3 deletions src/main/frontend/app/routes/projectlanding/project-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ import useEditorTabStore from '~/stores/editor-tab-store'
import {
cloneProject,
createProject,
DEFAULT_MAX_IMPORT_BYTES,
exportProject,
importProjectFolder,
ImportTooLargeError,
openProject,
} from '~/services/project-service'
import { useRecentProjects } from '~/hooks/use-projects'
import { showErrorToast } from '~/components/toast'
import { showErrorToast, showWarningToast } from '~/components/toast'

export default function ProjectLanding() {
const navigate = useNavigate()
Expand All @@ -46,6 +48,7 @@ export default function ProjectLanding() {
const [isDiscovering, setIsDiscovering] = useState(false)
const [ffConfiguration, setFFConfiguration] = useState<FFConfiguration[]>([])
const [ffInstanceName, setFFInstanceName] = useState('')
const [maxImportBytes, setMaxImportBytes] = useState(DEFAULT_MAX_IMPORT_BYTES)
const importInputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
Expand All @@ -59,6 +62,7 @@ export default function ProjectLanding() {
.then((info) => {
setIsLocalEnvironment(info.isLocal)
setRootLocationName(info.isLocal ? 'Computer' : 'Cloud Workspace')
setMaxImportBytes(info.maxImportSize)
})
.catch((_) => {
showErrorToast('Failed to fetch environment info, defaulting to local mode.')
Expand Down Expand Up @@ -174,11 +178,26 @@ export default function ProjectLanding() {

setIsOpeningProject(true)
try {
const project = await importProjectFolder(files)
const project = await importProjectFolder(files, maxImportBytes)
openProjectAndNavigate(project)
refetch()
} catch (error) {
showErrorToast(error instanceof Error ? error.message : 'Failed to import project')
const limitMb = Math.round(maxImportBytes / (1024 * 1024))
if (error instanceof ImportTooLargeError) {
const sizeMb = (error.bytes / (1024 * 1024)).toFixed(1)
const dimension = error.kind === 'compressed' ? 'when zipped' : 'uncompressed'
showWarningToast(
`This configuration is ${sizeMb} MB ${dimension}, which exceeds the ${limitMb} MB limit. Please import a smaller folder.`,
'Configuration too large',
)
} else if (error instanceof ApiError && error.httpCode === 413) {
showWarningToast(
`This configuration is too large to import (over ${limitMb} MB). Please import a smaller folder.`,
'Configuration too large',
)
} else {
showErrorToast(error instanceof Error ? error.message : 'Failed to import project')
}
} finally {
setIsOpeningProject(false)
}
Expand Down
1 change: 1 addition & 0 deletions src/main/frontend/app/services/app-info-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

export interface AppInfo {
isLocal: boolean
maxImportSize: number
}

export async function fetchAppInfo(signal?: AbortSignal): Promise<AppInfo> {
Expand Down
56 changes: 56 additions & 0 deletions src/main/frontend/app/services/project-service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { DEFAULT_MAX_IMPORT_BYTES, importProjectFolder, ImportTooLargeError } from './project-service'

vi.mock('../utils/api', () => ({
apiFetch: vi.fn(() => Promise.resolve({ name: 'proj', rootPath: '/tmp/proj', filepaths: [] })),
apiUrl: (path: string) => path,
}))

vi.mock('fflate', () => ({
zipSync: (_entries: Record<string, Uint8Array>, _options: unknown) => new Uint8Array(8),
}))

function makeFile(relativePath: string, sizeOverride?: number): File {
const file = new File([new Uint8Array(4)], relativePath.split('/').pop() ?? 'file')
Object.defineProperty(file, 'webkitRelativePath', { value: relativePath })
Object.defineProperty(file, 'arrayBuffer', { value: () => Promise.resolve(new ArrayBuffer(4)) })
if (sizeOverride !== undefined) {
Object.defineProperty(file, 'size', { value: sizeOverride })
}
return file
}

function fileList(...files: File[]): FileList {
return files as unknown as FileList
}

describe('importProjectFolder', () => {
afterEach(() => {
vi.clearAllMocks()
})

it('rejects with an uncompressed ImportTooLargeError when the folder exceeds the limit', async () => {
const half = DEFAULT_MAX_IMPORT_BYTES / 2
const files = fileList(makeFile('proj/big1.bin', half + 1), makeFile('proj/big2.bin', half + 1))

const error = await importProjectFolder(files, DEFAULT_MAX_IMPORT_BYTES).catch((error_: unknown) => error_)

expect(error).toBeInstanceOf(ImportTooLargeError)
expect((error as ImportTooLargeError).kind).toBe('uncompressed')
expect((error as ImportTooLargeError).bytes).toBe((half + 1) * 2)
})

it('ignores the top-level folder entry (empty relative path) when summing sizes', async () => {
const files = fileList(makeFile('proj', DEFAULT_MAX_IMPORT_BYTES), makeFile('proj/Configuration.xml', 10))

await expect(importProjectFolder(files, DEFAULT_MAX_IMPORT_BYTES)).resolves.toMatchObject({ name: 'proj' })
})

it('uploads folders that stay within the limit', async () => {
const files = fileList(
makeFile('proj/Configuration.xml', 100),
makeFile('proj/src/main/resources/application.properties', 50),
)

await expect(importProjectFolder(files, DEFAULT_MAX_IMPORT_BYTES)).resolves.toMatchObject({ name: 'proj' })
})
})
52 changes: 45 additions & 7 deletions src/main/frontend/app/services/project-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { zipSync } from 'fflate'
import { apiFetch, apiUrl } from '~/utils/api'
import type { ConfigurationProject } from '~/types/project.types'

export const DEFAULT_MAX_IMPORT_BYTES = 80 * 1024 * 1024

export class ImportTooLargeError extends Error {
constructor(
public readonly bytes: number,
public readonly kind: 'compressed' | 'uncompressed' = 'compressed',
) {
super('Configuration is too large to import')
this.name = 'ImportTooLargeError'
}
}

export async function fetchProject(name: string): Promise<ConfigurationProject> {
return apiFetch<ConfigurationProject>(`/projects/${encodeURIComponent(name)}`)
}
Expand Down Expand Up @@ -52,21 +65,46 @@ export async function exportProject(projectName: string): Promise<void> {
URL.revokeObjectURL(a.href)
}

export async function importProjectFolder(files: FileList): Promise<ConfigurationProject> {
const formData = new FormData()
export async function importProjectFolder(files: FileList, maxImportBytes: number): Promise<ConfigurationProject> {
const projectName = files[0].webkitRelativePath.split('/')[0]

const firstPath = files[0].webkitRelativePath
const projectName = firstPath.split('/')[0]
formData.append('projectName', projectName)
/*
* pre-check on the uncompressed size so an oversized folder fails fast,
*/
let uncompressedBytes = 0
for (const file of files) {
if (file.webkitRelativePath.split('/').slice(1).join('/')) {
uncompressedBytes += file.size
}
}

if (uncompressedBytes > maxImportBytes) {
throw new ImportTooLargeError(uncompressedBytes, 'uncompressed')
}

const entries: Record<string, Uint8Array> = {}
for (const file of files) {
formData.append('files', file)
const relativePath = file.webkitRelativePath.split('/').slice(1).join('/')
formData.append('paths', relativePath)
if (!relativePath) continue
entries[relativePath] = new Uint8Array(await file.arrayBuffer())
}

const archive = await zipAsync(entries)

if (archive.byteLength > maxImportBytes) {
throw new ImportTooLargeError(archive.byteLength, 'compressed')
}

const formData = new FormData()
formData.append('projectName', projectName)
formData.append('file', new Blob([archive], { type: 'application/zip' }), `${projectName}.zip`)

return apiFetch<ConfigurationProject>('/projects/import', {
method: 'POST',
body: formData,
})
}

async function zipAsync(entries: Record<string, Uint8Array>): Promise<Uint8Array<ArrayBuffer>> {
return zipSync(entries, { level: 6 }) as Uint8Array<ArrayBuffer>
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be an async function to get the reject behaviour you throw the error

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh it didnt grab the correct lines, sorry
But it is that whole function since it now just return new promise which an async function does automatically

1 change: 1 addition & 0 deletions src/main/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"dotenv": "^17.4.2",
"fflate": "^0.8.3",
"isbot": "^5.1.39",
"monaco-xsd-code-completion": "^1.1.1",
"react": "^19.2.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.Map;
import org.frankframework.flow.filesystem.FileSystemStorage;
import org.frankframework.flow.project.ImportProperties;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -10,13 +11,18 @@
@RequestMapping("/app-info")
public class AppInfoController {
private final FileSystemStorage fileSystemStorage;
private final ImportProperties importProperties;

public AppInfoController(FileSystemStorage fileSystemStorage) {
public AppInfoController(FileSystemStorage fileSystemStorage, ImportProperties importProperties) {
this.fileSystemStorage = fileSystemStorage;
this.importProperties = importProperties;
}

@GetMapping
public Map<String, Object> getInfo() {
return Map.of("isLocal", fileSystemStorage.isLocalEnvironment());
return Map.of(
"isLocal", fileSystemStorage.isLocalEnvironment(),
"maxImportSize", importProperties.maxUploadSize().toBytes()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import jakarta.servlet.MultipartConfigElement;
import org.frankframework.flow.project.ImportProperties;
import org.frankframework.management.gateway.InputStreamHttpMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.FormHttpMessageConverter;
Expand All @@ -20,10 +23,13 @@
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableConfigurationProperties(ImportProperties.class)
public class WebConfiguration implements WebMvcConfigurer {

private static final long MAX_AGE_SECONDS = 3600;

private static final long MULTIPART_REQUEST_HEADROOM_BYTES = 5L * 1024 * 1024;

@Value("${cors.allowed.origins:}")
private String[] allowedOrigins;

Expand Down Expand Up @@ -59,6 +65,12 @@ public void configureMessageConverters(HttpMessageConverters.ServerBuilder build
builder.addCustomConverter(new FormHttpMessageConverter());
}

@Bean
public MultipartConfigElement multipartConfigElement(ImportProperties importProperties) {
long maxFileSize = importProperties.maxUploadSize().toBytes();
long maxRequestSize = maxFileSize + MULTIPART_REQUEST_HEADROOM_BYTES;
return new MultipartConfigElement(null, maxFileSize, maxRequestSize, 0);
}

@Bean
public ObjectMapper objectMapper() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,15 @@ public void exportProject(@PathVariable String projectName, HttpServletResponse

@PostMapping("/import")
public ResponseEntity<ConfigurationProjectDTO> importProject(
@RequestParam("files") List<MultipartFile> files,
@RequestParam("paths") List<String> paths,
@RequestParam("file") MultipartFile file,
@RequestParam("projectName") String projectName
) throws IOException {
if (files.isEmpty() || files.size() != paths.size()) {
if (file.isEmpty()) {
log.warn("Rejected import for project \"{}\": uploaded file is empty", projectName);
return ResponseEntity.badRequest().build();
}

ConfigurationProject configurationProject = configurationProjectService.importProjectFromFiles(projectName, files, paths);
ConfigurationProject configurationProject = configurationProjectService.importProjectFromZip(projectName, file);
recentProjectsService.addRecentProject(configurationProject.getName(), configurationProject.getRootPath());
return ResponseEntity.ok(configurationProjectService.toDto(configurationProject));
}
Expand Down
Loading
Loading