diff --git a/bugsink/urls.py b/bugsink/urls.py
index 80a32f5..381d1b7 100644
--- a/bugsink/urls.py
+++ b/bugsink/urls.py
@@ -55,6 +55,7 @@ urlpatterns = [
path('teams/', include('teams.urls')),
path('events/', include('events.urls')),
path('issues/', include('issues.urls')),
+ path('files/', include('files.urls')),
path('admin/', admin.site.urls),
diff --git a/files/admin.py b/files/admin.py
index b5163c4..198bdf5 100644
--- a/files/admin.py
+++ b/files/admin.py
@@ -1,4 +1,7 @@
from django.contrib import admin
+from django.urls import reverse
+from django.utils.html import format_html
+
from .models import Chunk, File, FileMetadata
@@ -11,9 +14,15 @@ class ChunkAdmin(admin.ModelAdmin):
@admin.register(File)
class FileAdmin(admin.ModelAdmin):
- list_display = ('filename', 'checksum', 'size')
+ list_display = ('filename', 'checksum', 'size', 'download_link')
search_fields = ('checksum',)
- readonly_fields = ('data',)
+ readonly_fields = ('data', 'download_link')
+
+ def download_link(self, obj):
+ return format_html(
+ '{}',
+ reverse("download_file", args=(obj.checksum,)), str(obj.filename),
+ )
@admin.register(FileMetadata)
diff --git a/files/urls.py b/files/urls.py
new file mode 100644
index 0000000..29ac7fc
--- /dev/null
+++ b/files/urls.py
@@ -0,0 +1,27 @@
+from django.urls import path
+from django.urls import register_converter
+
+from .views import download_file
+
+
+def regex_converter(passed_regex):
+ # copy/pasta w/ issues/urls.py
+
+ class RegexConverter:
+ regex = passed_regex
+
+ def to_python(self, value):
+ return value
+
+ def to_url(self, value):
+ return value
+
+ return RegexConverter
+
+
+register_converter(regex_converter("[0-9a-f]{40}"), "sha1")
+
+
+urlpatterns = [
+ path('downloads//', download_file, name='download_file'),
+]
diff --git a/files/views.py b/files/views.py
index 90e1dd2..fff8d4a 100644
--- a/files/views.py
+++ b/files/views.py
@@ -3,9 +3,11 @@ import json
from hashlib import sha1
from gzip import GzipFile
from io import BytesIO
+from os.path import basename
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
+from django.contrib.auth.decorators import user_passes_test
from sentry.assemble import ChunkFileState
@@ -154,7 +156,7 @@ def assemble_artifact_bundle(bundle_checksum, chunk_checksums):
checksum = sha1(file_data).hexdigest()
- filename = manifest_entry.get("url", filename)[:255]
+ filename = basename(manifest_entry.get("url", filename))[:255]
file, _ = File.objects.get_or_create(
checksum=checksum,
@@ -226,3 +228,11 @@ def artifact_bundle_assemble(request, organization_slug):
# NOTE: as it stands, we process the bundle inline, so arguably we could return "OK" here too; "CREATED" is what
# sentry returns though, so for faithful mimicking it's the safest bet.
return JsonResponse({"state": ChunkFileState.CREATED, "missingChunks": []})
+
+
+@user_passes_test(lambda u: u.is_superuser)
+def download_file(request, checksum):
+ file = File.objects.get(checksum=checksum)
+ response = HttpResponse(file.data, content_type="application/octet-stream")
+ response["Content-Disposition"] = f"attachment; filename={file.filename}"
+ return response