Skip to content

Commit 1027469

Browse files
rbro112jasonyuezhang
authored andcommitted
Add app-icon download endpoint
1 parent c0de8e1 commit 1027469

File tree

4 files changed

+151
-0
lines changed

4 files changed

+151
-0
lines changed

src/sentry/objectstore/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from sentry.objectstore.service import ClientBuilder
22

33
attachments = ClientBuilder("attachments")
4+
app_icons = ClientBuilder("app-icons")
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from django.http import HttpResponse
6+
from rest_framework.request import Request
7+
8+
from sentry.api.api_owners import ApiOwner
9+
from sentry.api.api_publish_status import ApiPublishStatus
10+
from sentry.api.base import region_silo_endpoint
11+
from sentry.api.bases.project import ProjectEndpoint
12+
from sentry.models.project import Project
13+
from sentry.objectstore import app_icons
14+
from sentry.objectstore.service import ClientError
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def detect_image_content_type(image_data: bytes) -> str:
20+
"""
21+
Detect the content type of an image from its magic bytes.
22+
Returns the appropriate MIME type or a default if unknown.
23+
"""
24+
if not image_data:
25+
return "application/octet-stream"
26+
27+
# Check magic bytes for common image formats
28+
if image_data[:8] == b"\x89PNG\r\n\x1a\n":
29+
return "image/png"
30+
elif image_data[:3] == b"\xff\xd8\xff":
31+
return "image/jpeg"
32+
elif image_data[:4] == b"RIFF" and image_data[8:12] == b"WEBP":
33+
return "image/webp"
34+
elif image_data[:2] in (b"BM", b"BA", b"CI", b"CP", b"IC", b"PT"):
35+
return "image/bmp"
36+
elif image_data[:6] in (b"GIF87a", b"GIF89a"):
37+
return "image/gif"
38+
elif image_data[:4] == b"\x00\x00\x01\x00":
39+
return "image/x-icon"
40+
elif len(image_data) >= 12 and image_data[4:12] in (b"ftypavif", b"ftypavis"):
41+
return "image/avif"
42+
elif len(image_data) >= 12 and image_data[4:12] in (
43+
b"ftypheic",
44+
b"ftypheix",
45+
b"ftyphevc",
46+
b"ftyphevx",
47+
):
48+
return "image/heic"
49+
50+
# Default to generic binary if we can't detect the type
51+
logger.warning(
52+
"Could not detect image content type from magic bytes",
53+
extra={"first_bytes": image_data[:16].hex() if len(image_data) >= 16 else image_data.hex()},
54+
)
55+
return "application/octet-stream"
56+
57+
58+
@region_silo_endpoint
59+
class ProjectPreprodArtifactIconEndpoint(ProjectEndpoint):
60+
owner = ApiOwner.EMERGE_TOOLS
61+
publish_status = {
62+
"GET": ApiPublishStatus.EXPERIMENTAL,
63+
}
64+
65+
def get(
66+
self,
67+
request: Request,
68+
project: Project,
69+
app_icon_id: str,
70+
) -> HttpResponse:
71+
organization_id = project.organization_id
72+
project_id = project.id
73+
74+
# object_key = f"{organization_id}/{project_id}/{app_icon_id}"
75+
logger.info(
76+
"Retrieving app icon from objectstore",
77+
extra={
78+
"organization_id": organization_id,
79+
"project_id": project_id,
80+
"app_icon_id": app_icon_id,
81+
},
82+
)
83+
client = app_icons.for_project(organization_id, project_id)
84+
85+
try:
86+
result = client.get(app_icon_id)
87+
# Read the entire stream at once
88+
image_data = result.payload.read()
89+
90+
# Detect content type from the image data
91+
content_type = detect_image_content_type(image_data)
92+
93+
logger.info(
94+
"Retrieved app icon from objectstore",
95+
extra={
96+
"organization_id": organization_id,
97+
"project_id": project_id,
98+
"app_icon_id": app_icon_id,
99+
"size_bytes": len(image_data),
100+
"content_type": content_type,
101+
},
102+
)
103+
return HttpResponse(image_data, content_type=content_type)
104+
105+
except ClientError as e:
106+
if e.status == 404:
107+
logger.warning(
108+
"App icon not found in objectstore",
109+
extra={
110+
"organization_id": organization_id,
111+
"project_id": project_id,
112+
"app_icon_id": app_icon_id,
113+
},
114+
)
115+
116+
# Upload failed, return appropriate error
117+
return HttpResponse({"error": "Not found"}, status=404)
118+
119+
logger.warning(
120+
"Failed to retrieve app icon from objectstore",
121+
extra={
122+
"organization_id": organization_id,
123+
"project_id": project_id,
124+
"app_icon_id": app_icon_id,
125+
"error": str(e),
126+
"status": e.status,
127+
},
128+
)
129+
return HttpResponse({"error": "Failed to retrieve app icon"}, status=500)
130+
131+
except Exception:
132+
logger.exception(
133+
"Unexpected error retrieving app icon",
134+
extra={
135+
"organization_id": organization_id,
136+
"project_id": project_id,
137+
"app_icon_id": app_icon_id,
138+
},
139+
)
140+
return HttpResponse({"error": "Internal server error"}, status=500)

src/sentry/preprod/api/endpoints/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
from django.urls import re_path
44

5+
from sentry.preprod.api.endpoints.project_preprod_artifact_icon import (
6+
ProjectPreprodArtifactIconEndpoint,
7+
)
58
from sentry.preprod.api.endpoints.size_analysis.project_preprod_size_analysis_compare import (
69
ProjectPreprodArtifactSizeAnalysisCompareEndpoint,
710
)
@@ -81,6 +84,11 @@
8184
ProjectInstallablePreprodArtifactDownloadEndpoint.as_view(),
8285
name="sentry-api-0-installable-preprod-artifact-download",
8386
),
87+
re_path(
88+
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/files/app-icons/(?P<app_icon_id>[^/]+)/$",
89+
ProjectPreprodArtifactIconEndpoint.as_view(),
90+
name="sentry-api-0-project-preprod-app-icon",
91+
),
8492
# Size analysis
8593
re_path(
8694
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/preprodartifacts/size-analysis/compare/(?P<head_artifact_id>[^/]+)/(?P<base_artifact_id>[^/]+)/$",

src/sentry/preprod/api/models/project_preprod_build_details_models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class BuildDetailsAppInfo(BaseModel):
2929
platform: Platform | None = None
3030
is_installable: bool
3131
build_configuration: str | None = None
32+
app_icon_id: str | None = None
3233

3334

3435
class BuildDetailsVcsInfo(BaseModel):
@@ -152,6 +153,7 @@ def transform_preprod_artifact_to_build_details(
152153
build_configuration=(
153154
artifact.build_configuration.name if artifact.build_configuration else None
154155
),
156+
app_icon_id=artifact.app_icon_id,
155157
)
156158

157159
vcs_info = BuildDetailsVcsInfo(

0 commit comments

Comments
 (0)