diff --git a/frontend/src/components/listing/ListingArtifactRenderer.tsx b/frontend/src/components/listing/ListingArtifactRenderer.tsx
index af26b793..dd8b719a 100644
--- a/frontend/src/components/listing/ListingArtifactRenderer.tsx
+++ b/frontend/src/components/listing/ListingArtifactRenderer.tsx
@@ -1,9 +1,10 @@
-import { FaFileArchive } from "react-icons/fa";
+import { FaFile, FaFileArchive } from "react-icons/fa";
import { Link } from "react-router-dom";
import placeholder from "@/components/listing/pics/placeholder.jpg";
import { Artifact } from "@/components/listing/types";
import ROUTES from "@/lib/types/routes";
+import { formatFileSize } from "@/lib/utils/formatters";
interface Props {
artifact: Artifact;
@@ -30,7 +31,27 @@ const ListingArtifactRenderer = ({ artifact }: Props) => {
+
+
+
+
+
+ {artifact.name}
+
+
Kernel Image File
+ {artifact.size && (
+
{formatFileSize(artifact.size)}
+ )}
+
{new Date(artifact.timestamp * 1000).toLocaleString()}
diff --git a/frontend/src/components/listing/ListingImageGallery.tsx b/frontend/src/components/listing/ListingImageGallery.tsx
index f8f8922b..6ca3f2b4 100644
--- a/frontend/src/components/listing/ListingImageGallery.tsx
+++ b/frontend/src/components/listing/ListingImageGallery.tsx
@@ -1,10 +1,12 @@
import { useState } from "react";
-import { FaStar, FaTimes } from "react-icons/fa";
+import { FaFileDownload, FaStar, FaTimes } from "react-icons/fa";
import { Artifact } from "@/components/listing/types";
import DeleteConfirmationModal from "@/components/modals/DeleteConfirmationModal";
+import DownloadConfirmationModal from "@/components/modals/DownloadConfirmationModal";
import { useAlertQueue } from "@/hooks/useAlertQueue";
import { useAuthentication } from "@/hooks/useAuth";
+import { formatFileSize } from "@/lib/utils/formatters";
import { Tooltip } from "../ui/ToolTip";
import { Button } from "../ui/button";
@@ -41,6 +43,7 @@ const ListingImageItem = ({
const [isDeleting, setIsDeleting] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [showDownloadModal, setShowDownloadModal] = useState(false);
const auth = useAuthentication();
const { addErrorAlert } = useAlertQueue();
@@ -121,6 +124,29 @@ const ListingImageItem = ({
}
};
+ const isKernelImage =
+ artifact.name.toLowerCase().endsWith(".img") ||
+ artifact.artifact_type === "kernel";
+
+ const initiateDownload = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setShowDownloadModal(true);
+ };
+
+ const handleDownload = () => {
+ if (!artifact.urls.large) {
+ addErrorAlert("Artifact URL not available.");
+ return;
+ }
+
+ const link = document.createElement("a");
+ link.href = artifact.urls.large;
+ link.download = artifact.name;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
return (
<>
)}
+ {isKernelImage && (
+
+
+
+ )}
+ setShowDownloadModal(false)}
+ onDownload={handleDownload}
+ fileName={artifact.name}
+ fileSize={artifact.size ? formatFileSize(artifact.size) : "Unknown"}
+ />
>
);
};
diff --git a/frontend/src/components/listing/ListingOnshape.tsx b/frontend/src/components/listing/ListingOnshape.tsx
index 820a3a1f..06a34b7e 100644
--- a/frontend/src/components/listing/ListingOnshape.tsx
+++ b/frontend/src/components/listing/ListingOnshape.tsx
@@ -298,6 +298,7 @@ const ListingOnshape = (props: Props) => {
const [permUrl, setPermUrl] = useState(onshapeUrl);
const [updateOnshape, setUpdateOnshape] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
+ const [showKernelInstructions, setShowKernelInstructions] = useState(false);
const handleCopy = async () => {
try {
@@ -369,6 +370,10 @@ const ListingOnshape = (props: Props) => {
setShowInstructions(!showInstructions);
};
+ const toggleKernelInstructions = () => {
+ setShowKernelInstructions(!showKernelInstructions);
+ };
+
const renderUrdfInstructions = (listingId: string) => (
@@ -393,19 +398,56 @@ const ListingOnshape = (props: Props) => {
);
+ const renderKernelInstructions = (listingId: string) => (
+
+
+ Kernel Image Upload Instructions
+
+
+ -
+ Install the K-Scale CLI:
+
+
+ -
+ Upload your kernel image:
+
+
+
+
+ );
+
const renderContent = () => {
if (isEditing) {
return (
{edit && (
-
{}}
- onSave={handleSave}
- onToggleInstructions={toggleInstructions}
- showInstructions={showInstructions}
- />
+
+
+
+
+
)}
);
@@ -418,10 +460,20 @@ const ListingOnshape = (props: Props) => {
Add Onshape URL
{edit && (
-
+ <>
+
+
+ >
)}
);
@@ -473,6 +525,7 @@ const ListingOnshape = (props: Props) => {
{renderContent()}
{showInstructions && renderUrdfInstructions(listingId)}
+ {showKernelInstructions && renderKernelInstructions(listingId)}
)}
diff --git a/frontend/src/components/modals/DownloadConfirmationModal.tsx b/frontend/src/components/modals/DownloadConfirmationModal.tsx
new file mode 100644
index 00000000..0565a673
--- /dev/null
+++ b/frontend/src/components/modals/DownloadConfirmationModal.tsx
@@ -0,0 +1,55 @@
+import Modal from "@/components/ui/Modal";
+import { Button } from "@/components/ui/button";
+
+interface DownloadConfirmationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onDownload: () => void;
+ fileName: string;
+ fileSize: string;
+}
+
+const DownloadConfirmationModal = ({
+ isOpen,
+ onClose,
+ onDownload,
+ fileName,
+ fileSize,
+}: DownloadConfirmationModalProps) => {
+ return (
+
+
+
+ Download Kernel Image
+
+
+
Are you sure you want to download this kernel image?
+
+ File:
+ {fileName}
+
+
+ Size:
+ {fileSize}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DownloadConfirmationModal;
diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts
index 35c37369..9761df01 100644
--- a/frontend/src/gen/api.ts
+++ b/frontend/src/gen/api.ts
@@ -324,6 +324,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/artifacts/presigned/{listing_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Get Presigned Url */
+ post: operations["get_presigned_url_artifacts_presigned__listing_id__post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/keys/new": {
parameters: {
query?: never;
@@ -1790,6 +1807,13 @@ export interface components {
order: components["schemas"]["Order"];
product: components["schemas"]["ProductInfo"] | null;
};
+ /** PresignedUrlResponse */
+ PresignedUrlResponse: {
+ /** Upload Url */
+ upload_url: string;
+ /** Artifact Id */
+ artifact_id: string;
+ };
/** ProcessPreorderResponse */
ProcessPreorderResponse: {
/** Status */
@@ -1925,7 +1949,7 @@ export interface components {
/** Name */
name: string;
/** Artifact Type */
- artifact_type: "image" | ("urdf" | "mjcf") | ("stl" | "obj" | "dae" | "ply") | ("tgz" | "zip");
+ artifact_type: "image" | "kernel" | ("urdf" | "mjcf") | ("stl" | "obj" | "dae" | "ply") | ("tgz" | "zip");
/** Description */
description: string | null;
/** Timestamp */
@@ -1941,6 +1965,8 @@ export interface components {
* @default false
*/
can_edit: boolean;
+ /** Size */
+ size?: number | null;
};
/** SingleRobotResponse */
SingleRobotResponse: {
@@ -2459,7 +2485,7 @@ export interface operations {
};
header?: never;
path: {
- artifact_type: "image" | ("urdf" | "mjcf") | ("stl" | "obj" | "dae" | "ply") | ("tgz" | "zip");
+ artifact_type: "image" | "kernel" | ("urdf" | "mjcf") | ("stl" | "obj" | "dae" | "ply") | ("tgz" | "zip");
listing_id: string;
name: string;
};
@@ -2683,6 +2709,39 @@ export interface operations {
};
};
};
+ get_presigned_url_artifacts_presigned__listing_id__post: {
+ parameters: {
+ query: {
+ filename: string;
+ };
+ header?: never;
+ path: {
+ listing_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PresignedUrlResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
new_key_keys_new_post: {
parameters: {
query?: never;
diff --git a/frontend/src/lib/utils/formatters.ts b/frontend/src/lib/utils/formatters.ts
new file mode 100644
index 00000000..1d693657
--- /dev/null
+++ b/frontend/src/lib/utils/formatters.ts
@@ -0,0 +1,7 @@
+export const formatFileSize = (bytes: number): string => {
+ if (!bytes) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+};
diff --git a/store/app/crud/base.py b/store/app/crud/base.py
index 2c405f9e..bc3e9bce 100644
--- a/store/app/crud/base.py
+++ b/store/app/crud/base.py
@@ -553,3 +553,55 @@ async def _get_by_known_id(self, record_id: str) -> dict[str, Any] | None:
table = await self.db.Table(TABLE_NAME)
response = await table.get_item(Key={"id": record_id})
return response.get("Item")
+
+ async def generate_presigned_upload_url(
+ self, filename: str, s3_key: str, content_type: str, checksum_algorithm: str = "SHA256", expires_in: int = 3600
+ ) -> str:
+ """Generates a presigned URL for uploading a file to S3.
+
+ Args:
+ filename: Original filename for Content-Disposition
+ s3_key: The S3 key where the file will be stored
+ content_type: The content type of the file
+ checksum_algorithm: Algorithm used for upload integrity verification (SHA256, SHA1, CRC32)
+ expires_in: Number of seconds until URL expires
+
+ Returns:
+ Presigned URL for uploading
+ """
+ try:
+ return await self.s3.meta.client.generate_presigned_url(
+ ClientMethod="put_object",
+ Params={
+ "Bucket": settings.s3.bucket,
+ "Key": f"{settings.s3.prefix}{s3_key}",
+ "ContentType": content_type,
+ "ContentDisposition": f'attachment; filename="{filename}"',
+ "ChecksumAlgorithm": checksum_algorithm,
+ },
+ ExpiresIn=expires_in,
+ )
+ except ClientError as e:
+ logger.error("Failed to generate presigned URL: %s", e)
+ raise
+
+ async def get_file_size(self, filename: str) -> int | None:
+ """Gets the size of a file in S3.
+
+ Args:
+ filename: The name of the file
+
+ Returns:
+ The size in bytes, or None if the file doesn't exist
+ """
+ try:
+ s3_object = await self.s3.meta.client.head_object(
+ Bucket=settings.s3.bucket, Key=f"{settings.s3.prefix}{filename}"
+ )
+ return s3_object.get("ContentLength")
+ except ClientError as e:
+ logger.error("Failed to get S3 object size: %s", e)
+ return None
+ except Exception as e:
+ logger.error("Unexpected error getting file size: %s", e)
+ return None
diff --git a/store/app/model.py b/store/app/model.py
index 1d0e6a64..ec9573c5 100644
--- a/store/app/model.py
+++ b/store/app/model.py
@@ -173,7 +173,8 @@ def create(cls, user_id: str, source: APIKeySource, permissions: APIKeyPermissio
XMLArtifactType = Literal["urdf", "mjcf"]
MeshArtifactType = Literal["stl", "obj", "dae", "ply"]
CompressedArtifactType = Literal["tgz", "zip"]
-ArtifactType = ImageArtifactType | XMLArtifactType | MeshArtifactType | CompressedArtifactType
+KernelArtifactType = Literal["kernel"]
+ArtifactType = ImageArtifactType | KernelArtifactType | XMLArtifactType | MeshArtifactType | CompressedArtifactType
UPLOAD_CONTENT_TYPE_OPTIONS: dict[ArtifactType, set[str]] = {
# Image
@@ -210,6 +211,7 @@ def create(cls, user_id: str, source: APIKeySource, permissions: APIKeyPermissio
# Compressed
"tgz": "application/gzip",
"zip": "application/zip",
+ "kernel": "application/octet-stream",
}
SizeMapping: dict[ArtifactSize, tuple[int, int]] = {
@@ -219,21 +221,10 @@ def create(cls, user_id: str, source: APIKeySource, permissions: APIKeyPermissio
def get_artifact_type(content_type: str | None, filename: str | None) -> ArtifactType:
- """Determines the artifact type from the content type or filename.
-
- Args:
- content_type: The content type of the file.
- filename: The name of the file.
-
- Returns:
- The artifact type.
-
- Raises:
- ValueError: If the artifact type cannot be determined.
- """
- # Attempts to determine from file extension.
if filename is not None:
extension = filename.split(".")[-1].lower()
+ if extension == "img":
+ return "kernel"
if extension in ("png", "jpeg", "jpg", "gif", "webp"):
return "image"
if extension in ("urdf",):
@@ -255,6 +246,8 @@ def get_artifact_type(content_type: str | None, filename: str | None) -> Artifac
# Attempts to determine from content type.
if content_type is not None:
+ if content_type in UPLOAD_CONTENT_TYPE_OPTIONS["kernel"]:
+ return "kernel"
if content_type in UPLOAD_CONTENT_TYPE_OPTIONS["image"]:
return "image"
if content_type in UPLOAD_CONTENT_TYPE_OPTIONS["urdf"]:
@@ -274,11 +267,13 @@ def get_artifact_type(content_type: str | None, filename: str | None) -> Artifac
if content_type in UPLOAD_CONTENT_TYPE_OPTIONS["zip"]:
return "zip"
- # Throws a value error if the type cannot be determined.
raise ValueError(f"Unknown content type for file: {filename}")
def get_compression_type(content_type: str | None, filename: str | None) -> CompressedArtifactType:
+ if filename is None:
+ raise ValueError("Filename must be provided")
+
artifact_type = get_artifact_type(content_type, filename)
if artifact_type not in (allowed_types := get_args(CompressedArtifactType)):
raise ValueError(f"Artifact type {artifact_type} is not compressed; expected one of {allowed_types}")
@@ -462,7 +457,7 @@ def get_artifact_name(
case "image":
height, width = SizeMapping[size]
return f"{listing_id}/{artifact_id}/{size}_{height}x{width}_{name}"
- case "urdf" | "mjcf" | "stl" | "obj" | "ply" | "dae" | "zip" | "tgz":
+ case "kernel" | "urdf" | "mjcf" | "stl" | "obj" | "ply" | "dae" | "zip" | "tgz":
return f"{listing_id}/{artifact_id}/{name}"
case _:
raise ValueError(f"Unknown artifact type: {artifact_type}")
diff --git a/store/app/routers/artifacts.py b/store/app/routers/artifacts.py
index 0f87f592..99e370f3 100644
--- a/store/app/routers/artifacts.py
+++ b/store/app/routers/artifacts.py
@@ -4,6 +4,7 @@
import logging
import os
from datetime import datetime, timedelta
+from pathlib import Path
from typing import Annotated, Literal, Self
from boto3.dynamodb.conditions import Key
@@ -16,11 +17,13 @@
Artifact,
ArtifactSize,
ArtifactType,
+ KernelArtifactType,
Listing,
User,
can_write_artifact,
can_write_listing,
check_content_type,
+ get_artifact_name,
get_artifact_type,
get_artifact_urls,
)
@@ -133,6 +136,7 @@ class SingleArtifactResponse(BaseModel):
urls: ArtifactUrls
is_main: bool = False
can_edit: bool = False
+ size: int | None = None
@classmethod
async def from_artifact(
@@ -185,6 +189,9 @@ async def get_listing(listing: Listing | None) -> Listing:
get_listing(listing),
)
+ s3_filename = get_artifact_name(artifact=artifact)
+ size = await crud.get_file_size(s3_filename) if crud is not None else None
+
return cls(
artifact_id=artifact.id,
listing_id=artifact.listing_id,
@@ -197,6 +204,7 @@ async def get_listing(listing: Listing | None) -> Listing:
urls=get_artifact_url_response(artifact=artifact),
is_main=artifact.is_main,
can_edit=can_edit,
+ size=size,
)
@@ -411,3 +419,59 @@ async def set_main_image(
await crud.set_main_image(listing_id, artifact_id)
return True
+
+
+class PresignedUrlResponse(BaseModel):
+ upload_url: str
+ artifact_id: str
+
+
+@router.post("/presigned/{listing_id}", response_model=PresignedUrlResponse)
+async def get_presigned_url(
+ listing_id: str,
+ filename: str,
+ user: Annotated[User, Depends(get_session_user_with_write_permission)],
+ crud: Annotated[Crud, Depends(Crud.get)],
+) -> PresignedUrlResponse:
+ if not filename:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Filename was not provided",
+ )
+
+ if not Path(filename).suffix.lower() == ".img":
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail="Only .img files are supported for kernel uploads"
+ )
+
+ artifact_type: KernelArtifactType = "kernel"
+
+ listing = await crud.get_listing(listing_id)
+ if listing is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Could not find listing")
+ if not await can_write_listing(user, listing):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission to upload")
+
+ artifact = Artifact.create(
+ user_id=listing.user_id,
+ listing_id=listing.id,
+ name=filename,
+ artifact_type=artifact_type,
+ )
+ await crud._add_item(artifact)
+
+ try:
+ s3_filename = get_artifact_name(artifact=artifact)
+
+ presigned_url = await crud.generate_presigned_upload_url(
+ filename=filename,
+ s3_key=s3_filename,
+ content_type="application/x-raw-disk-image",
+ checksum_algorithm="SHA256",
+ )
+
+ return PresignedUrlResponse(upload_url=presigned_url, artifact_id=artifact.id)
+
+ except Exception as e:
+ await crud._delete_item(artifact)
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))