|
@@ -1,11 +1,22 @@
|
|
|
+import mimetypes
|
|
|
+import random
|
|
|
+from dataclasses import dataclass
|
|
|
+from io import BytesIO
|
|
|
+from typing import IO, Optional
|
|
|
+
|
|
|
+import zstandard
|
|
|
+from django.conf import settings
|
|
|
from django.core.cache import cache
|
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
|
from django.db import models
|
|
|
from django.utils import timezone
|
|
|
|
|
|
+from sentry import options
|
|
|
+from sentry.attachments.base import CachedAttachment
|
|
|
from sentry.backup.scopes import RelocationScope
|
|
|
from sentry.db.models import BoundedBigIntegerField, Model, region_silo_only_model, sane_repr
|
|
|
from sentry.db.models.fields.bounded import BoundedIntegerField
|
|
|
+from sentry.models.files.utils import get_size_and_checksum, get_storage
|
|
|
|
|
|
# Attachment file types that are considered a crash report (PII relevant)
|
|
|
CRASH_REPORT_TYPES = ("event.minidump", "event.applecrashreport")
|
|
@@ -29,8 +40,28 @@ def event_attachment_screenshot_filter(queryset):
|
|
|
)
|
|
|
|
|
|
|
|
|
+@dataclass(frozen=True)
|
|
|
+class PutfileResult:
|
|
|
+ content_type: str
|
|
|
+ size: int
|
|
|
+ sha1: str
|
|
|
+ file_id: Optional[int] = None
|
|
|
+ blob_path: Optional[str] = None
|
|
|
+
|
|
|
+
|
|
|
@region_silo_only_model
|
|
|
class EventAttachment(Model):
|
|
|
+ """Attachment Metadata and Storage
|
|
|
+
|
|
|
+ The actual attachment data can be saved in different backing stores:
|
|
|
+ - Using the :class:`File` model using the `file_id` field.
|
|
|
+ This stores attachments chunked and deduplicated.
|
|
|
+ - When the `blob_path` field has a `eventattachments/v1/` prefix:
|
|
|
+ In this case, the default :func:`get_storage` is used as the backing store.
|
|
|
+ The attachment data is not chunked or deduplicated in this case.
|
|
|
+ However, it is `zstd` compressed.
|
|
|
+ """
|
|
|
+
|
|
|
__relocation_scope__ = RelocationScope.Excluded
|
|
|
|
|
|
# the things we want to look up attachments by:
|
|
@@ -60,8 +91,6 @@ class EventAttachment(Model):
|
|
|
__repr__ = sane_repr("event_id", "name")
|
|
|
|
|
|
def delete(self, *args, **kwargs):
|
|
|
- from sentry.models.files.file import File
|
|
|
-
|
|
|
rv = super().delete(*args, **kwargs)
|
|
|
|
|
|
if self.group_id and self.type in CRASH_REPORT_TYPES:
|
|
@@ -70,7 +99,18 @@ class EventAttachment(Model):
|
|
|
# repopulated with the next incoming crash report.
|
|
|
cache.delete(get_crashreport_key(self.group_id))
|
|
|
|
|
|
+ if self.blob_path:
|
|
|
+ if self.blob_path.startswith("eventattachments/v1/"):
|
|
|
+ storage = get_storage()
|
|
|
+ else:
|
|
|
+ raise NotImplementedError()
|
|
|
+
|
|
|
+ storage.delete(self.blob_path)
|
|
|
+ return rv
|
|
|
+
|
|
|
try:
|
|
|
+ from sentry.models.files.file import File
|
|
|
+
|
|
|
file = File.objects.get(id=self.file_id)
|
|
|
except ObjectDoesNotExist:
|
|
|
# It's possible that the File itself was deleted
|
|
@@ -82,3 +122,59 @@ class EventAttachment(Model):
|
|
|
file.delete()
|
|
|
|
|
|
return rv
|
|
|
+
|
|
|
+ def getfile(self) -> IO:
|
|
|
+ if self.blob_path:
|
|
|
+ if self.blob_path.startswith("eventattachments/v1/"):
|
|
|
+ storage = get_storage()
|
|
|
+ compressed_blob = storage.open(self.blob_path)
|
|
|
+ dctx = zstandard.ZstdDecompressor()
|
|
|
+ return dctx.stream_reader(compressed_blob, read_across_frames=True)
|
|
|
+ else:
|
|
|
+ raise NotImplementedError()
|
|
|
+
|
|
|
+ from sentry.models.files.file import File
|
|
|
+
|
|
|
+ file = File.objects.get(id=self.file_id)
|
|
|
+ return file.getfile()
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def putfile(cls, project_id: int, attachment: CachedAttachment) -> PutfileResult:
|
|
|
+ from sentry.models.files import File, FileBlob
|
|
|
+
|
|
|
+ blob = BytesIO(attachment.data)
|
|
|
+ content_type = normalize_content_type(attachment.content_type, attachment.name)
|
|
|
+
|
|
|
+ store_blobs = project_id in options.get("eventattachments.store-blobs.projects") or (
|
|
|
+ random.random() < options.get("eventattachments.store-blobs.sample-rate")
|
|
|
+ )
|
|
|
+
|
|
|
+ if store_blobs:
|
|
|
+ size, checksum = get_size_and_checksum(blob)
|
|
|
+ blob_path = "eventattachments/v1/" + FileBlob.generate_unique_path()
|
|
|
+
|
|
|
+ storage = get_storage()
|
|
|
+ cctx = zstandard.ZstdCompressor()
|
|
|
+ compressed_blob = cctx.stream_reader(blob)
|
|
|
+ storage.save(blob_path, compressed_blob)
|
|
|
+
|
|
|
+ return PutfileResult(
|
|
|
+ content_type=content_type, size=size, sha1=checksum, blob_path=blob_path
|
|
|
+ )
|
|
|
+
|
|
|
+ file = File.objects.create(
|
|
|
+ name=attachment.name,
|
|
|
+ type=attachment.type,
|
|
|
+ headers={"Content-Type": content_type},
|
|
|
+ )
|
|
|
+ file.putfile(blob, blob_size=settings.SENTRY_ATTACHMENT_BLOB_SIZE)
|
|
|
+
|
|
|
+ return PutfileResult(
|
|
|
+ content_type=content_type, size=file.size, sha1=file.checksum, file_id=file.id
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def normalize_content_type(content_type: str | None, name: str) -> str:
|
|
|
+ if content_type:
|
|
|
+ return content_type.split(";")[0].strip()
|
|
|
+ return mimetypes.guess_type(name)[0] or "application/octet-stream"
|