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
10 changes: 10 additions & 0 deletions src/main/java/io/hyperfoil/tools/h5m/api/UploadRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.hyperfoil.tools.h5m.api;

import io.hyperfoil.tools.jjq.value.JqValue;
import org.eclipse.microprofile.openapi.annotations.media.Schema;

@Schema(description = "Request body for uploading JSON data to a folder")
public record UploadRequest(

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 object is not needed. I would rather see the different types uploads to be sent as MULTIPART_FORM_DATA (openAPI generators will create some kind of request object from it)

this would be what the endpoint would look like:

import static jakarta.ws.rs.core.MediaType.MULTIPART_FORM_DATA;

import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;

    @POST
    @Path("{id}/upload")
    @Consumes(MULTIPART_FORM_DATA)
    @Operation(description = "Upload JSON data to a folder. Returns immediately with an uploadId.")
    @APIResponse(responseCode = "400", description = "Request received but context is not valid format")
    @Authenticated
    long upload(@RestForm("raw") String raw, @RestForm("url") URL url, @RestForm("file") FileUpload file) {
        if (raw != null) { ... }
        if (url != null) { ... }
        if (file != null) { ... }
}

Tests will require io.restassured multipart builders instead of simple .body(json).

@Schema(description = "JSON data to upload (file or paste JSON)") JqValue file,
@Schema(description = "URL to fetch JSON from") String url) {
}
33 changes: 31 additions & 2 deletions src/main/java/io/hyperfoil/tools/h5m/rest/FolderResource.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package io.hyperfoil.tools.h5m.rest;

import io.hyperfoil.tools.jjq.value.JqValue;
import io.hyperfoil.tools.jjq.value.JqValues;
import jakarta.json.Json;
import jakarta.json.JsonException;
import io.hyperfoil.tools.h5m.api.Folder;
import io.hyperfoil.tools.h5m.api.FolderSummary;
import io.hyperfoil.tools.h5m.api.UploadRequest;
import io.hyperfoil.tools.h5m.api.RecalculationStatus;
import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface;
import io.hyperfoil.tools.h5m.api.svc.ValueServiceInterface;
Expand All @@ -13,11 +17,16 @@
import jakarta.inject.Inject;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;

import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;

Expand Down Expand Up @@ -87,9 +96,29 @@ public void deleteFolder(@PathParam("id") long id) {
@Operation(description = "Upload JSON data to a folder. Returns immediately with an uploadId.")
public long upload(
@PathParam("id") long id,
JqValue data) {
UploadRequest body) {
String url = body != null ? body.url() : null;
JqValue data = null;
if (url != null && !url.isEmpty()) {
try (var inputStream = URI.create(url).toURL().openStream()) {

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.

for the URL parameter, verify that it's schema is HTTP / HTTPS to prevent some SSRF attacks that look into the local filesystem (like file://, jar://) and other protocols like ftp://

  if (!Set.of("http", "https").contains(uri.getScheme())) {                                                                                                                        
      throw new BadRequestException("Only http/https URLs are allowed");                                                                                                           
  } 

String raw = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
try (var reader = Json.createReader(new StringReader(raw))) {
reader.readValue();
} catch (JsonException e) {
throw new WebApplicationException(Response.status(400).entity("Invalid JSON from URL '" + url + "': " + e.getMessage()).type(MediaType.TEXT_PLAIN).build());
}
data = JqValues.parse(raw);
} catch (IOException e) {
throw new WebApplicationException(Response.status(400).entity("Failed to fetch URL '" + url + "': " + e.getMessage()).type(MediaType.TEXT_PLAIN).build());
} catch (Exception e) {
throw new WebApplicationException(Response.status(400).entity("Invalid JSON from URL '" + url + "': " + e.getMessage()).type(MediaType.TEXT_PLAIN).build());
}
} else if (body != null && body.file() != null) {
data = body.file();
}

if (data == null) {
throw new BadRequestException("Missing request body");
throw new BadRequestException("Provide either 'file', raw data, (JSON data) or 'url' in the request body");
}
return folderService.upload(id, data).uploadId;
}
Expand Down
67 changes: 66 additions & 1 deletion src/main/webui/src/app/components/DataTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { View, ViewComponent } from '@client/types.gen.ts';

import { UploadDataModal } from '@app/components/UploadDataModal';
import { ViewConfigModal } from '@app/components/ViewConfigModal';
import {
Button,
Expand All @@ -8,6 +9,11 @@ import {
ErrorBoundary,
InlineLoading,
SkeletonText,
StructuredListBody,
StructuredListCell,
StructuredListHead,
StructuredListRow,
StructuredListWrapper,
Table,
TableBody,
TableCell,
Expand Down Expand Up @@ -91,6 +97,8 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb
const [configModalOpen, setConfigModalOpen] = useState(false);
const [editingView, setEditingView] = useState<View | null>(null);
const [modalKey, setModalKey] = useState(0);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [recentUploads, setRecentUploads] = useState<Array<{ fileName: string; uploadId: number }>>([]);

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.

could also track the upload time


const selectedView = useMemo((): View | null => {
if (!views || views.length === 0) return null;
Expand All @@ -107,7 +115,61 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb
}, [views, selectedViewId]);

if (viewsLoading) return <SkeletonText paragraph={true} lineCount={3} />;
if (!views || views.length === 0) return <p>No views configured</p>;

const uploadButton = (
<Button kind="primary" size="md" onClick={() => setUploadModalOpen(true)}>
Upload data
</Button>
);

const uploadModal = (
<ErrorBoundary fallback={<InlineLoading status="error" description="Failed to load modal" />}>
<Suspense fallback={null}>
<UploadDataModal
open={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
folderId={folderId}
onUploadSuccess={(fileName, uploadId) => {
setRecentUploads((prev) => [{ fileName, uploadId }, ...prev]);
}}
/>
</Suspense>
</ErrorBoundary>
);

const recentUploadsList = recentUploads.length > 0 ? (
<div style={{ marginBottom: 'var(--cds-spacing-05)' }}>
<StructuredListWrapper>
<StructuredListHead>
<StructuredListRow head>
<StructuredListCell head>Upload file</StructuredListCell>
<StructuredListCell head>Upload ID</StructuredListCell>
</StructuredListRow>
</StructuredListHead>
<StructuredListBody>
{recentUploads.map((item, i) => (
<StructuredListRow key={`${item.uploadId}-${String(i)}`}>
<StructuredListCell>{item.fileName}</StructuredListCell>
<StructuredListCell>
<span style={{ fontWeight: 600, color: 'var(--cds-support-success)' }}>
#{item.uploadId}
</span>
</StructuredListCell>
</StructuredListRow>
))}
</StructuredListBody>
</StructuredListWrapper>
</div>
) : null;

if (!views || views.length === 0) return (
<>
<div style={{ marginBottom: 'var(--cds-spacing-05)' }}>{uploadButton}</div>
{recentUploadsList}
<p>No views configured</p>
{uploadModal}
</>
);

const dropdownItems = views.map((v: View) => ({
id: String(v.id),
Expand All @@ -117,6 +179,7 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb
return (
<div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--cds-spacing-03)', marginBottom: 'var(--cds-spacing-05)' }}>
{uploadButton}
<div style={{ maxWidth: '300px', flex: 1 }}>
<Dropdown
id="view-selector"
Expand Down Expand Up @@ -150,6 +213,7 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb
New View
</Button>
</div>
{recentUploadsList}
{selectedView && (!selectedView.components || selectedView.components.length === 0) && (
<p style={{ opacity: 0.7 }}>
This view has no columns configured. Click <strong>Configure</strong> to select which nodes to display.
Expand All @@ -174,6 +238,7 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb
/>
</Suspense>
</ErrorBoundary>
{uploadModal}
</div>
);
};
Loading
Loading