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}
-
+
+ {new Date(artifact.timestamp * 1000).toLocaleString()} +
+
+
+ ); + case "kernel": + return ( +
+
+ +
+
+
+ {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 && ( + + + + )} +
)}
); @@ -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))