Skip to content

Commit d1e1502

Browse files
committed
Squashed all commits of sdko/core/revamp-s3
1 parent a3f8611 commit d1e1502

File tree

13 files changed

+2600
-198
lines changed

13 files changed

+2600
-198
lines changed

authentik/core/api/applications.py

+20-14
Original file line numberDiff line numberDiff line change
@@ -281,42 +281,48 @@ def list(self, request: Request) -> Response:
281281
serializer = self.get_serializer(allowed_applications, many=True)
282282
return self.get_paginated_response(serializer.data)
283283

284+
@action(
285+
detail=True,
286+
pagination_class=None,
287+
filter_backends=[],
288+
methods=["POST"],
289+
parser_classes=(MultiPartParser,),
290+
)
284291
@permission_required("authentik_core.change_application")
285292
@extend_schema(
286293
request={
287294
"multipart/form-data": FileUploadSerializer,
288295
},
289296
responses={
290297
200: OpenApiResponse(description="Success"),
291-
400: OpenApiResponse(description="Bad request"),
298+
400: OpenApiResponse(description="Bad request", response={"error": str}),
299+
403: OpenApiResponse(description="Permission denied", response={"error": str}),
300+
415: OpenApiResponse(description="Unsupported Media Type", response={"error": str}),
301+
500: OpenApiResponse(description="Internal server error", response={"error": str}),
292302
},
293303
)
304+
def set_icon(self, request: Request, slug: str):
305+
"""Set application icon"""
306+
app: Application = self.get_object()
307+
return set_file(request, app, "meta_icon")
308+
294309
@action(
295310
detail=True,
296311
pagination_class=None,
297312
filter_backends=[],
298313
methods=["POST"],
299-
parser_classes=(MultiPartParser,),
300314
)
301-
def set_icon(self, request: Request, slug: str):
302-
"""Set application icon"""
303-
app: Application = self.get_object()
304-
return set_file(request, app, "meta_icon")
305-
306315
@permission_required("authentik_core.change_application")
307316
@extend_schema(
308317
request=FilePathSerializer,
309318
responses={
310319
200: OpenApiResponse(description="Success"),
311-
400: OpenApiResponse(description="Bad request"),
320+
400: OpenApiResponse(description="Bad request", response={"error": str}),
321+
403: OpenApiResponse(description="Permission denied", response={"error": str}),
322+
415: OpenApiResponse(description="Unsupported Media Type", response={"error": str}),
323+
500: OpenApiResponse(description="Internal server error", response={"error": str}),
312324
},
313325
)
314-
@action(
315-
detail=True,
316-
pagination_class=None,
317-
filter_backends=[],
318-
methods=["POST"],
319-
)
320326
def set_icon_url(self, request: Request, slug: str):
321327
"""Set application icon (as URL)"""
322328
app: Application = self.get_object()

authentik/core/api/sources.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ def get_queryset(self): # pragma: no cover
9898
},
9999
responses={
100100
200: OpenApiResponse(description="Success"),
101-
400: OpenApiResponse(description="Bad request"),
101+
400: OpenApiResponse(description="Bad request", response={"error": str}),
102+
403: OpenApiResponse(description="Permission denied", response={"error": str}),
103+
415: OpenApiResponse(description="Unsupported Media Type", response={"error": str}),
104+
500: OpenApiResponse(description="Internal server error", response={"error": str}),
102105
},
103106
)
104107
@action(
@@ -118,7 +121,10 @@ def set_icon(self, request: Request, slug: str):
118121
request=FilePathSerializer,
119122
responses={
120123
200: OpenApiResponse(description="Success"),
121-
400: OpenApiResponse(description="Bad request"),
124+
400: OpenApiResponse(description="Bad request", response={"error": str}),
125+
403: OpenApiResponse(description="Permission denied", response={"error": str}),
126+
415: OpenApiResponse(description="Unsupported Media Type", response={"error": str}),
127+
500: OpenApiResponse(description="Internal server error", response={"error": str}),
122128
},
123129
)
124130
@action(

authentik/core/models.py

+12
Original file line numberDiff line numberDiff line change
@@ -553,9 +553,21 @@ def get_meta_icon(self) -> str | None:
553553
"""Get the URL to the App Icon image. If the name is /static or starts with http
554554
it is returned as-is"""
555555
if not self.meta_icon:
556+
LOGGER.debug("No meta_icon set")
556557
return None
558+
559+
LOGGER.debug(
560+
"Getting meta_icon URL",
561+
name=self.meta_icon.name,
562+
url=self.meta_icon.url if hasattr(self.meta_icon, "url") else None,
563+
storage_backend=self.meta_icon.storage.__class__.__name__,
564+
)
565+
557566
if "://" in self.meta_icon.name or self.meta_icon.name.startswith("/static"):
567+
LOGGER.debug("Using direct meta_icon name", name=self.meta_icon.name)
558568
return self.meta_icon.name
569+
570+
LOGGER.debug("Using storage URL", url=self.meta_icon.url)
559571
return self.meta_icon.url
560572

561573
def get_launch_url(self, user: Optional["User"] = None) -> str | None:

authentik/core/tests/test_applications_api.py

+77-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Test Applications API"""
22

3+
import io
34
from json import loads
45

56
from django.core.files.base import ContentFile
7+
from django.core.files.uploadedfile import InMemoryUploadedFile
68
from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
79
from django.urls import reverse
8-
from rest_framework.test import APITestCase
10+
from PIL import Image
11+
from rest_framework.test import APITransactionTestCase
912

1013
from authentik.core.models import Application
1114
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
@@ -17,7 +20,7 @@
1720
from authentik.providers.saml.models import SAMLProvider
1821

1922

20-
class TestApplicationsAPI(APITestCase):
23+
class TestApplicationsAPI(APITransactionTestCase):
2124
"""Test applications API"""
2225

2326
def setUp(self) -> None:
@@ -40,6 +43,30 @@ def setUp(self) -> None:
4043
policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2),
4144
order=0,
4245
)
46+
self.test_files = []
47+
48+
def tearDown(self) -> None:
49+
# Clean up any test files
50+
for app in [self.allowed, self.denied]:
51+
if app.meta_icon:
52+
app.meta_icon.delete()
53+
super().tearDown()
54+
55+
def create_test_image(self, name="test.png") -> ContentFile:
56+
"""Create a valid test PNG image file.
57+
58+
Args:
59+
name: The name to give the test file
60+
61+
Returns:
62+
ContentFile: A ContentFile containing a valid PNG image
63+
"""
64+
# Create a small test image
65+
image = Image.new("RGB", (1, 1), color="red")
66+
img_io = io.BytesIO()
67+
image.save(img_io, format="PNG")
68+
img_io.seek(0)
69+
return ContentFile(img_io.getvalue(), name=name)
4370

4471
def test_formatted_launch_url(self):
4572
"""Test formatted launch URL"""
@@ -58,19 +85,34 @@ def test_formatted_launch_url(self):
5885
)
5986

6087
def test_set_icon(self):
61-
"""Test set_icon"""
62-
file = ContentFile(b"text", "name")
88+
"""Test set_icon and cleanup"""
89+
# Create a test image file with a simple name
90+
image = Image.new("RGB", (1, 1), color="red")
91+
img_io = io.BytesIO()
92+
image.save(img_io, format="PNG")
93+
img_io.seek(0)
94+
file = InMemoryUploadedFile(
95+
img_io,
96+
"file",
97+
"test_icon.png",
98+
"image/png",
99+
len(img_io.getvalue()),
100+
None,
101+
)
63102
self.client.force_login(self.user)
103+
104+
# Test setting icon
64105
response = self.client.post(
65106
reverse(
66107
"authentik_api:application-set-icon",
67108
kwargs={"slug": self.allowed.slug},
68109
),
69-
data=encode_multipart(data={"file": file}, boundary=BOUNDARY),
110+
data=encode_multipart(BOUNDARY, {"file": file}),
70111
content_type=MULTIPART_CONTENT,
71112
)
72113
self.assertEqual(response.status_code, 200)
73114

115+
# Verify icon was set correctly
74116
app_raw = self.client.get(
75117
reverse(
76118
"authentik_api:application-detail",
@@ -80,7 +122,36 @@ def test_set_icon(self):
80122
app = loads(app_raw.content)
81123
self.allowed.refresh_from_db()
82124
self.assertEqual(self.allowed.get_meta_icon, app["meta_icon"])
83-
self.assertEqual(self.allowed.meta_icon.read(), b"text")
125+
file.seek(0)
126+
self.assertEqual(self.allowed.meta_icon.read(), file.read())
127+
128+
# Test icon replacement
129+
new_image = Image.new("RGB", (1, 1), color="blue")
130+
new_img_io = io.BytesIO()
131+
new_image.save(new_img_io, format="PNG")
132+
new_img_io.seek(0)
133+
new_file = InMemoryUploadedFile(
134+
new_img_io,
135+
"file",
136+
"new_icon.png",
137+
"image/png",
138+
len(new_img_io.getvalue()),
139+
None,
140+
)
141+
response = self.client.post(
142+
reverse(
143+
"authentik_api:application-set-icon",
144+
kwargs={"slug": self.allowed.slug},
145+
),
146+
data=encode_multipart(BOUNDARY, {"file": new_file}),
147+
content_type=MULTIPART_CONTENT,
148+
)
149+
self.assertEqual(response.status_code, 200)
150+
151+
# Verify new icon was set and old one was cleaned up
152+
self.allowed.refresh_from_db()
153+
new_file.seek(0)
154+
self.assertEqual(self.allowed.meta_icon.read(), new_file.read())
84155

85156
def test_check_access(self):
86157
"""Test check_access operation"""

authentik/flows/api/flows.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ def diagram(self, request: Request, slug: str) -> Response:
242242
},
243243
responses={
244244
200: OpenApiResponse(description="Success"),
245-
400: OpenApiResponse(description="Bad request"),
245+
400: OpenApiResponse(description="Bad request", response={"error": str}),
246+
403: OpenApiResponse(description="Permission denied", response={"error": str}),
247+
415: OpenApiResponse(description="Unsupported Media Type", response={"error": str}),
248+
500: OpenApiResponse(description="Internal server error", response={"error": str}),
246249
},
247250
)
248251
@action(
@@ -262,7 +265,10 @@ def set_background(self, request: Request, slug: str):
262265
request=FilePathSerializer,
263266
responses={
264267
200: OpenApiResponse(description="Success"),
265-
400: OpenApiResponse(description="Bad request"),
268+
400: OpenApiResponse(description="Bad request", response={"error": str}),
269+
403: OpenApiResponse(description="Permission denied", response={"error": str}),
270+
415: OpenApiResponse(description="Unsupported Media Type", response={"error": str}),
271+
500: OpenApiResponse(description="Internal server error", response={"error": str}),
266272
},
267273
)
268274
@action(

authentik/lib/utils/file.py

+52-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""file utils"""
22

3+
import os
4+
5+
from django.core.exceptions import SuspiciousOperation
36
from django.db.models import Model
47
from django.http import HttpResponseBadRequest
58
from rest_framework.fields import BooleanField, CharField, FileField
@@ -12,6 +15,15 @@
1215
LOGGER = get_logger()
1316

1417

18+
class FileValidationError(SuspiciousOperation):
19+
"""Custom exception for file validation errors."""
20+
21+
def __init__(self, message: str, status_code: int = 400):
22+
super().__init__(message)
23+
self.status_code = status_code
24+
self.user_message = message
25+
26+
1527
class FileUploadSerializer(PassiveSerializer):
1628
"""Serializer to upload file"""
1729

@@ -30,19 +42,55 @@ def set_file(request: Request, obj: Model, field_name: str):
3042
field = getattr(obj, field_name)
3143
file = request.FILES.get("file", None)
3244
clear = request.data.get("clear", "false").lower() == "true"
45+
46+
# If clearing or replacing, delete the old file first
47+
if (clear or file) and field:
48+
try:
49+
LOGGER.debug(
50+
"Deleting old file before setting new one",
51+
field_name=field_name,
52+
old_file=field.name if field else None,
53+
)
54+
# Delete old file but don't save model yet
55+
field.delete(save=False)
56+
except Exception as exc:
57+
LOGGER.warning("Failed to delete old file", exc=exc)
58+
3359
if clear:
34-
# .delete() saves the model by default
35-
field.delete()
60+
# Save model after clearing
61+
obj.save()
3662
return Response({})
63+
3764
if file:
65+
# Get the upload_to path from the model field
66+
upload_to = field.field.upload_to
67+
# If upload_to is set, ensure the file name includes the directory
68+
if upload_to:
69+
# Use basename to strip any path components from the filename
70+
base_name = os.path.basename(file.name)
71+
# Construct a clean path within the upload directory
72+
file.name = f"{upload_to}/{base_name}"
3873
setattr(obj, field_name, file)
3974
try:
4075
obj.save()
76+
except FileValidationError as exc:
77+
LOGGER.warning(
78+
"File validation failed",
79+
error=exc.user_message,
80+
status_code=exc.status_code,
81+
field=field_name,
82+
)
83+
return Response({"error": exc.user_message}, status=exc.status_code)
4184
except PermissionError as exc:
4285
LOGGER.warning("Failed to save file", exc=exc)
43-
return HttpResponseBadRequest()
86+
return Response({"error": "Permission denied saving file"}, status=403)
87+
except Exception as exc:
88+
LOGGER.error("Unexpected error saving file", exc=exc)
89+
return Response(
90+
{"error": "An unexpected error occurred while saving the file"}, status=500
91+
)
4492
return Response({})
45-
return HttpResponseBadRequest()
93+
return Response({"error": "No file provided"}, status=400)
4694

4795

4896
def set_file_url(request: Request, obj: Model, field: str):

authentik/root/settings.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@
203203
],
204204
"DEFAULT_PARSER_CLASSES": [
205205
"drf_orjson_renderer.parsers.ORJSONParser",
206+
"rest_framework.parsers.MultiPartParser",
206207
],
207208
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
208209
"TEST_REQUEST_DEFAULT_FORMAT": "json",
@@ -406,6 +407,8 @@
406407

407408

408409
# Media files
410+
TEST = False
411+
409412
if CONFIG.get("storage.media.backend", "file") == "s3":
410413
STORAGES["default"] = {
411414
"BACKEND": "authentik.root.storages.S3Storage",
@@ -430,6 +433,17 @@
430433
"custom_domain": CONFIG.get("storage.media.s3.custom_domain", None),
431434
},
432435
}
436+
if TEST:
437+
STORAGES["default"]["OPTIONS"].update(
438+
{
439+
"access_key": "test-key",
440+
"secret_key": "test-secret",
441+
"bucket_name": "test-bucket",
442+
"region_name": "us-east-1",
443+
"endpoint_url": "http://localhost:8020",
444+
"use_ssl": False,
445+
}
446+
)
433447
# Fallback on file storage backend
434448
else:
435449
STORAGES["default"] = {
@@ -444,7 +458,6 @@
444458
MEDIA_ROOT = STORAGES["default"]["OPTIONS"]["location"]
445459
MEDIA_URL = STORAGES["default"]["OPTIONS"]["base_url"]
446460

447-
TEST = False
448461
TEST_RUNNER = "authentik.root.test_runner.PytestTestRunner"
449462

450463
structlog_configure()

0 commit comments

Comments
 (0)