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
1 change: 1 addition & 0 deletions src/sentry/objectstore/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from sentry.objectstore.service import ClientBuilder

attachments = ClientBuilder("attachments")
preprod = ClientBuilder("preprod")
140 changes: 140 additions & 0 deletions src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from __future__ import annotations

import logging

from django.http import HttpResponse
from rest_framework.request import Request

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.project import ProjectEndpoint
from sentry.models.project import Project
from sentry.objectstore import preprod
from sentry.objectstore.service import ClientError

logger = logging.getLogger(__name__)


def detect_image_content_type(image_data: bytes) -> str:
"""
Detect the content type of an image from its magic bytes.
Returns the appropriate MIME type or a default if unknown.
"""
if not image_data:
return "application/octet-stream"

# Check magic bytes for common image formats
if image_data[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
elif image_data[:3] == b"\xff\xd8\xff":
return "image/jpeg"
elif image_data[:4] == b"RIFF" and image_data[8:12] == b"WEBP":
return "image/webp"
elif image_data[:2] in (b"BM", b"BA", b"CI", b"CP", b"IC", b"PT"):
return "image/bmp"
elif image_data[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
elif image_data[:4] == b"\x00\x00\x01\x00":
return "image/x-icon"
elif len(image_data) >= 12 and image_data[4:12] in (b"ftypavif", b"ftypavis"):
return "image/avif"
elif len(image_data) >= 12 and image_data[4:12] in (
b"ftypheic",
b"ftypheix",
b"ftyphevc",
b"ftyphevx",
):
return "image/heic"

# Default to generic binary if we can't detect the type
logger.warning(
"Could not detect image content type from magic bytes",
extra={"first_bytes": image_data[:16].hex() if len(image_data) >= 16 else image_data.hex()},
)
return "application/octet-stream"


@region_silo_endpoint
class ProjectPreprodArtifactImageEndpoint(ProjectEndpoint):
owner = ApiOwner.EMERGE_TOOLS
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}

def get(
self,
request: Request,
project: Project,
image_id: str,
) -> HttpResponse:
organization_id = project.organization_id
project_id = project.id

object_key = f"{organization_id}/{project_id}/{image_id}"
logger.info(
"Retrieving image from objectstore",
extra={
"organization_id": organization_id,
"project_id": project_id,
"image_id": image_id,
},
)
client = preprod.for_project(organization_id, project_id)

try:
result = client.get(object_key)
# Read the entire stream at once
image_data = result.payload.read()

# Detect content type from the image data
content_type = detect_image_content_type(image_data)

logger.info(
"Retrieved image from objectstore",
extra={
"organization_id": organization_id,
"project_id": project_id,
"image_id": image_id,
"size_bytes": len(image_data),
"content_type": content_type,
},
)
return HttpResponse(image_data, content_type=content_type)

except ClientError as e:
if e.status == 404:
logger.warning(
"App icon not found in objectstore",
extra={
"organization_id": organization_id,
"project_id": project_id,
"image_id": image_id,
},
)

# Upload failed, return appropriate error
return HttpResponse({"error": "Not found"}, status=404)
Copy link

Choose a reason for hiding this comment

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

Bug: HttpResponse is incorrectly used with dictionary objects at lines 117, 129, 140.
Severity: CRITICAL | Confidence: 1.00

🔍 Detailed Analysis

The code at lines 117, 129, and 140 attempts to return HttpResponse with Python dictionary objects, such as HttpResponse({"error": "Not found"}, status=404). The HttpResponse constructor does not accept dictionaries directly; it expects content as strings, bytes, or iterators. This will result in a TypeError during response serialization, causing a generic 500 error to be returned to the client instead of the intended structured error response.

💡 Suggested Fix

Replace HttpResponse with JsonResponse when returning dictionary objects. For example, change return HttpResponse({"error": "Not found"}, status=404) to return JsonResponse({"error": "Not found"}, status=404).

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/preprod/api/endpoints/project_preprod_artifact_image.py#L117

Potential issue: The code at lines 117, 129, and 140 attempts to return `HttpResponse`
with Python dictionary objects, such as `HttpResponse({"error": "Not found"},
status=404)`. The `HttpResponse` constructor does not accept dictionaries directly; it
expects content as strings, bytes, or iterators. This will result in a `TypeError`
during response serialization, causing a generic 500 error to be returned to the client
instead of the intended structured error response.

Did we get this right? 👍 / 👎 to inform future reviews.


logger.warning(
"Failed to retrieve app icon from objectstore",
extra={
"organization_id": organization_id,
"project_id": project_id,
"image_id": image_id,
"error": str(e),
"status": e.status,
},
)
return HttpResponse({"error": "Failed to retrieve app icon"}, status=500)

except Exception:
logger.exception(
"Unexpected error retrieving app icon",
extra={
"organization_id": organization_id,
"project_id": project_id,
"image_id": image_id,
},
)
return HttpResponse({"error": "Internal server error"}, status=500)
8 changes: 8 additions & 0 deletions src/sentry/preprod/api/endpoints/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from django.urls import re_path

from sentry.preprod.api.endpoints.project_preprod_artifact_icon import (
ProjectPreprodArtifactImageEndpoint,
Comment on lines +5 to +6
Copy link

Choose a reason for hiding this comment

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

Bug: urls.py imports ProjectPreprodArtifactImageEndpoint from a non-existent module project_preprod_artifact_icon.
Severity: CRITICAL | Confidence: 1.00

🔍 Detailed Analysis

The urls.py file attempts to import ProjectPreprodArtifactImageEndpoint from sentry.preprod.api.endpoints.project_preprod_artifact_icon on line 5. However, the module project_preprod_artifact_icon.py does not exist. The intended class ProjectPreprodArtifactImageEndpoint is defined in project_preprod_artifact_image.py. This mismatch will cause a ModuleNotFoundError when the application attempts to load URL patterns, preventing the application from starting.

💡 Suggested Fix

Correct the import path in src/sentry/preprod/api/endpoints/urls.py from sentry.preprod.api.endpoints.project_preprod_artifact_icon to sentry.preprod.api.endpoints.project_preprod_artifact_image.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/preprod/api/endpoints/urls.py#L5-L6

Potential issue: The `urls.py` file attempts to import
`ProjectPreprodArtifactImageEndpoint` from
`sentry.preprod.api.endpoints.project_preprod_artifact_icon` on line 5. However, the
module `project_preprod_artifact_icon.py` does not exist. The intended class
`ProjectPreprodArtifactImageEndpoint` is defined in `project_preprod_artifact_image.py`.
This mismatch will cause a `ModuleNotFoundError` when the application attempts to load
URL patterns, preventing the application from starting.

Did we get this right? 👍 / 👎 to inform future reviews.

)
from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import (
ProjectPreprodArtifactSizeAnalysisCompareEndpoint,
)
Expand Down Expand Up @@ -81,6 +84,11 @@
ProjectInstallablePreprodArtifactDownloadEndpoint.as_view(),
name="sentry-api-0-installable-preprod-artifact-download",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/files/images/(?P<image_id>[^/]+)/$",
ProjectPreprodArtifactImageEndpoint.as_view(),
name="sentry-api-0-project-preprod-app-icon",
),
# Size analysis
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/preprodartifacts/size-analysis/compare/(?P<head_artifact_id>[^/]+)/(?P<base_artifact_id>[^/]+)/$",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class BuildDetailsAppInfo(BaseModel):
platform: Platform | None = None
is_installable: bool
build_configuration: str | None = None
app_icon_id: str | None = None


class BuildDetailsVcsInfo(BaseModel):
Expand Down Expand Up @@ -152,6 +153,7 @@ def transform_preprod_artifact_to_build_details(
build_configuration=(
artifact.build_configuration.name if artifact.build_configuration else None
),
app_icon_id=artifact.app_icon_id,
)

vcs_info = BuildDetailsVcsInfo(
Expand Down
Loading