From 834ce3ae1e0e3ed1d45d2ba777e5bed6e8d9cc3b Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Fri, 24 Oct 2025 16:29:26 -0700 Subject: [PATCH 1/3] Add app-icon download endpoint --- src/sentry/objectstore/__init__.py | 1 + .../project_preprod_artifact_icon.py | 140 ++++++++++++++++++ src/sentry/preprod/api/endpoints/urls.py | 8 + .../project_preprod_build_details_models.py | 2 + 4 files changed, 151 insertions(+) create mode 100644 src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py diff --git a/src/sentry/objectstore/__init__.py b/src/sentry/objectstore/__init__.py index 1a01ef7149bf42..5b4d3255154106 100644 --- a/src/sentry/objectstore/__init__.py +++ b/src/sentry/objectstore/__init__.py @@ -1,3 +1,4 @@ from sentry.objectstore.service import ClientBuilder attachments = ClientBuilder("attachments") +app_icons = ClientBuilder("app-icons") diff --git a/src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py b/src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py new file mode 100644 index 00000000000000..7233e410274f3c --- /dev/null +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py @@ -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 app_icons +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 ProjectPreprodArtifactIconEndpoint(ProjectEndpoint): + owner = ApiOwner.EMERGE_TOOLS + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + } + + def get( + self, + request: Request, + project: Project, + app_icon_id: str, + ) -> HttpResponse: + organization_id = project.organization_id + project_id = project.id + + # object_key = f"{organization_id}/{project_id}/{app_icon_id}" + logger.info( + "Retrieving app icon from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "app_icon_id": app_icon_id, + }, + ) + client = app_icons.for_project(organization_id, project_id) + + try: + result = client.get(app_icon_id) + # 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 app icon from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "app_icon_id": app_icon_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, + "app_icon_id": app_icon_id, + }, + ) + + # Upload failed, return appropriate error + return HttpResponse({"error": "Not found"}, status=404) + + logger.warning( + "Failed to retrieve app icon from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "app_icon_id": app_icon_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, + "app_icon_id": app_icon_id, + }, + ) + return HttpResponse({"error": "Internal server error"}, status=500) diff --git a/src/sentry/preprod/api/endpoints/urls.py b/src/sentry/preprod/api/endpoints/urls.py index 720a4c7cd81df2..50cb87ef9f401b 100644 --- a/src/sentry/preprod/api/endpoints/urls.py +++ b/src/sentry/preprod/api/endpoints/urls.py @@ -2,6 +2,9 @@ from django.urls import re_path +from sentry.preprod.api.endpoints.project_preprod_artifact_icon import ( + ProjectPreprodArtifactIconEndpoint, +) from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import ( ProjectPreprodArtifactSizeAnalysisCompareEndpoint, ) @@ -81,6 +84,11 @@ ProjectInstallablePreprodArtifactDownloadEndpoint.as_view(), name="sentry-api-0-installable-preprod-artifact-download", ), + re_path( + r"^(?P[^/]+)/(?P[^/]+)/files/app-icons/(?P[^/]+)/$", + ProjectPreprodArtifactIconEndpoint.as_view(), + name="sentry-api-0-project-preprod-app-icon", + ), # Size analysis re_path( r"^(?P[^/]+)/(?P[^/]+)/preprodartifacts/size-analysis/compare/(?P[^/]+)/(?P[^/]+)/$", diff --git a/src/sentry/preprod/api/models/project_preprod_build_details_models.py b/src/sentry/preprod/api/models/project_preprod_build_details_models.py index b123c2efa98934..c0ca0bab987156 100644 --- a/src/sentry/preprod/api/models/project_preprod_build_details_models.py +++ b/src/sentry/preprod/api/models/project_preprod_build_details_models.py @@ -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): @@ -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( From c47c6ab177aa67a48fe3098abf5afbccf967cbf9 Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Fri, 24 Oct 2025 16:29:26 -0700 Subject: [PATCH 2/3] Add app-icon download endpoint --- src/sentry/objectstore/__init__.py | 1 + .../project_preprod_artifact_icon.py | 140 ++++++++++++++++++ src/sentry/preprod/api/endpoints/urls.py | 8 + .../project_preprod_build_details_models.py | 2 + 4 files changed, 151 insertions(+) create mode 100644 src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py diff --git a/src/sentry/objectstore/__init__.py b/src/sentry/objectstore/__init__.py index 1a01ef7149bf42..5b4d3255154106 100644 --- a/src/sentry/objectstore/__init__.py +++ b/src/sentry/objectstore/__init__.py @@ -1,3 +1,4 @@ from sentry.objectstore.service import ClientBuilder attachments = ClientBuilder("attachments") +app_icons = ClientBuilder("app-icons") diff --git a/src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py b/src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py new file mode 100644 index 00000000000000..7233e410274f3c --- /dev/null +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_icon.py @@ -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 app_icons +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 ProjectPreprodArtifactIconEndpoint(ProjectEndpoint): + owner = ApiOwner.EMERGE_TOOLS + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + } + + def get( + self, + request: Request, + project: Project, + app_icon_id: str, + ) -> HttpResponse: + organization_id = project.organization_id + project_id = project.id + + # object_key = f"{organization_id}/{project_id}/{app_icon_id}" + logger.info( + "Retrieving app icon from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "app_icon_id": app_icon_id, + }, + ) + client = app_icons.for_project(organization_id, project_id) + + try: + result = client.get(app_icon_id) + # 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 app icon from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "app_icon_id": app_icon_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, + "app_icon_id": app_icon_id, + }, + ) + + # Upload failed, return appropriate error + return HttpResponse({"error": "Not found"}, status=404) + + logger.warning( + "Failed to retrieve app icon from objectstore", + extra={ + "organization_id": organization_id, + "project_id": project_id, + "app_icon_id": app_icon_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, + "app_icon_id": app_icon_id, + }, + ) + return HttpResponse({"error": "Internal server error"}, status=500) diff --git a/src/sentry/preprod/api/endpoints/urls.py b/src/sentry/preprod/api/endpoints/urls.py index 720a4c7cd81df2..50cb87ef9f401b 100644 --- a/src/sentry/preprod/api/endpoints/urls.py +++ b/src/sentry/preprod/api/endpoints/urls.py @@ -2,6 +2,9 @@ from django.urls import re_path +from sentry.preprod.api.endpoints.project_preprod_artifact_icon import ( + ProjectPreprodArtifactIconEndpoint, +) from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import ( ProjectPreprodArtifactSizeAnalysisCompareEndpoint, ) @@ -81,6 +84,11 @@ ProjectInstallablePreprodArtifactDownloadEndpoint.as_view(), name="sentry-api-0-installable-preprod-artifact-download", ), + re_path( + r"^(?P[^/]+)/(?P[^/]+)/files/app-icons/(?P[^/]+)/$", + ProjectPreprodArtifactIconEndpoint.as_view(), + name="sentry-api-0-project-preprod-app-icon", + ), # Size analysis re_path( r"^(?P[^/]+)/(?P[^/]+)/preprodartifacts/size-analysis/compare/(?P[^/]+)/(?P[^/]+)/$", diff --git a/src/sentry/preprod/api/models/project_preprod_build_details_models.py b/src/sentry/preprod/api/models/project_preprod_build_details_models.py index b123c2efa98934..c0ca0bab987156 100644 --- a/src/sentry/preprod/api/models/project_preprod_build_details_models.py +++ b/src/sentry/preprod/api/models/project_preprod_build_details_models.py @@ -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): @@ -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( From 918f363cf9b633204d8b790e7c58261ae561ffab Mon Sep 17 00:00:00 2001 From: Ryan Brooks Date: Fri, 24 Oct 2025 16:29:49 -0700 Subject: [PATCH 3/3] Wireup app icon frontend --- .../sidebar/buildDetailsSidebarAppInfo.tsx | 21 ++++++++++++------- .../views/preprod/types/buildDetailsTypes.ts | 1 + 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx b/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx index 7a52ac701284dd..7c82db8242aa2d 100644 --- a/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx +++ b/static/app/views/preprod/buildDetails/sidebar/buildDetailsSidebarAppInfo.tsx @@ -10,6 +10,7 @@ import {t} from 'sentry/locale'; import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; import {getFormattedDate} from 'sentry/utils/dates'; import {unreachable} from 'sentry/utils/unreachable'; +import useOrganization from 'sentry/utils/useOrganization'; import {openInstallModal} from 'sentry/views/preprod/components/installModal'; import { BuildDetailsSizeAnalysisState, @@ -70,14 +71,21 @@ interface BuildDetailsSidebarAppInfoProps { } export function BuildDetailsSidebarAppInfo(props: BuildDetailsSidebarAppInfoProps) { + const organization = useOrganization(); const labels = getLabels(props.appInfo.platform ?? undefined); + let iconUrl = null; + if (props.appInfo.app_icon_id) { + iconUrl = `/api/0/projects/${organization.slug}/${props.projectId}/files/app-icons/${props.appInfo.app_icon_id}/`; + } + return ( - + {iconUrl && App Icon} + {!iconUrl && ( {props.appInfo.name?.charAt(0) || ''} - + )} {props.appInfo.name && {props.appInfo.name}} @@ -187,19 +195,16 @@ export function BuildDetailsSidebarAppInfo(props: BuildDetailsSidebarAppInfoProp ); } -const AppIcon = styled('div')` +const AppIconPlaceholder = styled('div')` width: 24px; height: 24px; border-radius: 4px; - background: #ff6600; display: flex; align-items: center; justify-content: center; flex-shrink: 0; -`; - -const AppIconPlaceholder = styled('div')` - color: white; + background: ${p => p.theme.purple400}; + color: ${p => p.theme.white}; font-weight: ${p => p.theme.fontWeight.bold}; font-size: ${p => p.theme.fontSize.sm}; `; diff --git a/static/app/views/preprod/types/buildDetailsTypes.ts b/static/app/views/preprod/types/buildDetailsTypes.ts index b6c777da03b491..bde9f1f31b2a46 100644 --- a/static/app/views/preprod/types/buildDetailsTypes.ts +++ b/static/app/views/preprod/types/buildDetailsTypes.ts @@ -9,6 +9,7 @@ export interface BuildDetailsApiResponse { } export interface BuildDetailsAppInfo { + app_icon_id?: string | null; app_id?: string | null; artifact_type?: BuildDetailsArtifactType | null; build_configuration?: string | null;