From 655e4a902dd35cab9d841d4b73e76480355eb74f Mon Sep 17 00:00:00 2001
From: Ivan <45982459+ivntsng@users.noreply.github.com>
Date: Wed, 20 Nov 2024 17:28:15 -0800
Subject: [PATCH] Kernel image upload cli (#630)

* Support for uploading kernel images via CLI

* Frontend failure & model changes

* Uploading kernel images via CLI

* Kernel-Images upload instructions

* Kernel image handling in listing view

* Ensure kernel images are downloaded with the correct format

* Added file size and download confirmation for kernel files

* Improve UI for kernel image

* Refactor S3 operations and additional changes

* Removed f-string from logger

* Added checksum for uploading kernel image
---
 .../listing/ListingArtifactRenderer.tsx       | 25 ++++++-
 .../listing/ListingImageGallery.tsx           | 46 +++++++++++-
 .../src/components/listing/ListingOnshape.tsx | 75 ++++++++++++++++---
 .../modals/DownloadConfirmationModal.tsx      | 55 ++++++++++++++
 frontend/src/gen/api.ts                       | 63 +++++++++++++++-
 frontend/src/lib/utils/formatters.ts          |  7 ++
 store/app/crud/base.py                        | 52 +++++++++++++
 store/app/model.py                            | 27 +++----
 store/app/routers/artifacts.py                | 64 ++++++++++++++++
 9 files changed, 382 insertions(+), 32 deletions(-)
 create mode 100644 frontend/src/components/modals/DownloadConfirmationModal.tsx
 create mode 100644 frontend/src/lib/utils/formatters.ts

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) => {
           </Link>
           <div className="text-center">
             <div className="font-medium">{artifact.name}</div>
-            <div className="text-sm">
+            <div className="text-xs">
+              {new Date(artifact.timestamp * 1000).toLocaleString()}
+            </div>
+          </div>
+        </div>
+      );
+    case "kernel":
+      return (
+        <div className="w-full h-full flex flex-col items-center justify-center gap-1 p-2">
+          <div className="p-2 sm:p-4">
+            <FaFile className="w-12 h-12 sm:w-16 sm:h-16" />
+          </div>
+          <div className="text-center w-full px-2">
+            <div className="font-medium text-sm sm:text-base truncate">
+              {artifact.name}
+            </div>
+            <div className="text-xs text-gray-500">Kernel Image File</div>
+            {artifact.size && (
+              <div className="text-xs">{formatFileSize(artifact.size)}</div>
+            )}
+            <div className="text-xs">
               {new Date(artifact.timestamp * 1000).toLocaleString()}
             </div>
           </div>
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 (
     <>
       <div
@@ -148,6 +174,17 @@ const ListingImageItem = ({
                 </Button>
               </Tooltip>
             )}
+            {isKernelImage && (
+              <Tooltip content="Download Kernel" position="left" size="sm">
+                <Button
+                  variant="default"
+                  onClick={initiateDownload}
+                  className="text-gray-12 bg-gray-2 hover:bg-gray-12 hover:text-gray-2"
+                >
+                  <FaFileDownload />
+                </Button>
+              </Tooltip>
+            )}
             <Tooltip content="Delete Image" position="left" size="sm">
               <Button
                 variant={isDeleting ? "ghost" : "destructive"}
@@ -171,6 +208,13 @@ const ListingImageItem = ({
         description="Are you sure you want to delete this image? This action cannot be undone."
         buttonText="Delete Image"
       />
+      <DownloadConfirmationModal
+        isOpen={showDownloadModal}
+        onClose={() => 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<string | null>(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) => (
     <div className="mt-6 p-8 bg-gray-12 rounded-xl shadow-lg w-full border border-gray-200">
       <h4 className="text-2xl font-semibold mb-6 text-gray-1">
@@ -393,19 +398,56 @@ const ListingOnshape = (props: Props) => {
     </div>
   );
 
+  const renderKernelInstructions = (listingId: string) => (
+    <div className="mt-6 p-8 bg-gray-12 rounded-xl shadow-lg w-full border border-gray-200">
+      <h4 className="text-2xl font-semibold mb-6 text-gray-1">
+        Kernel Image Upload Instructions
+      </h4>
+      <ol className="list-decimal list-outside ml-6 space-y-6 text-base text-gray-1">
+        <li className="leading-relaxed">
+          Install the K-Scale CLI:
+          <CopyableCode
+            code="pip install kscale"
+            className="bg-gray-12 text-green-600 p-4 rounded-lg mt-3 font-mono text-sm border border-gray-200"
+          />
+        </li>
+        <li className="leading-relaxed">
+          Upload your kernel image:
+          <CopyableCode
+            code={`kscale kernel-images upload ${listingId} /path/to/your/kernel/kernel_image.img`}
+            className="bg-gray-12 text-green-600 p-2 sm:p-4 rounded-lg mt-3 font-mono text-xs sm:text-sm border border-gray-200 break-all whitespace-pre-wrap"
+          />
+        </li>
+      </ol>
+    </div>
+  );
+
   const renderContent = () => {
     if (isEditing) {
       return (
         <div className="flex flex-col items-start w-full">
           <UrlInput url={url} setUrl={setUrl} handleSave={handleSave} />
           {edit && (
-            <UpdateButtons
-              isEditing={true}
-              onEdit={() => {}}
-              onSave={handleSave}
-              onToggleInstructions={toggleInstructions}
-              showInstructions={showInstructions}
-            />
+            <div className="flex flex-wrap gap-2 pt-2">
+              <IconButton
+                icon={FaCheck}
+                label="Save"
+                onClick={handleSave}
+                disabled={false}
+              />
+              <HelpButton
+                showInstructions={showInstructions}
+                onToggle={toggleInstructions}
+              />
+              <Button onClick={toggleKernelInstructions} variant="default">
+                <FaInfoCircle />
+                <span className="ml-2 text-sm sm:text-base">
+                  {showKernelInstructions
+                    ? "Hide Kernel Instructions"
+                    : "View Kernel Image Upload Instructions"}
+                </span>
+              </Button>
+            </div>
           )}
         </div>
       );
@@ -418,10 +460,20 @@ const ListingOnshape = (props: Props) => {
             Add Onshape URL
           </Button>
           {edit && (
-            <HelpButton
-              showInstructions={showInstructions}
-              onToggle={toggleInstructions}
-            />
+            <>
+              <HelpButton
+                showInstructions={showInstructions}
+                onToggle={toggleInstructions}
+              />
+              <Button onClick={toggleKernelInstructions} variant="default">
+                <FaInfoCircle />
+                <span className="ml-2 text-sm sm:text-base">
+                  {showKernelInstructions
+                    ? "Hide Kernel Instructions"
+                    : "View Kernel Image Upload Instructions"}
+                </span>
+              </Button>
+            </>
           )}
         </div>
       );
@@ -473,6 +525,7 @@ const ListingOnshape = (props: Props) => {
             <div className="flex flex-col w-full">
               {renderContent()}
               {showInstructions && renderUrdfInstructions(listingId)}
+              {showKernelInstructions && renderKernelInstructions(listingId)}
             </div>
           )}
         </CardTitle>
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 (
+    <Modal isOpen={isOpen} onClose={onClose}>
+      <div className="p-8 bg-gray-12 rounded-lg shadow-lg">
+        <h2 className="text-2xl font-bold mb-4 text-gray-2">
+          Download Kernel Image
+        </h2>
+        <div className="mb-6 space-y-2 text-gray-7">
+          <p>Are you sure you want to download this kernel image?</p>
+          <p>
+            <span className="text-gray-2 font-bold mb-4">File: </span>
+            {fileName}
+          </p>
+          <p>
+            <span className="text-gray-2 front-bold mb-4">Size: </span>
+            {fileSize}
+          </p>
+        </div>
+        <div className="flex justify-end space-x-4">
+          <Button onClick={onClose} variant="outline">
+            Cancel
+          </Button>
+          <Button
+            onClick={() => {
+              onDownload();
+              onClose();
+            }}
+            variant="default"
+          >
+            Download
+          </Button>
+        </div>
+      </div>
+    </Modal>
+  );
+};
+
+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))