Skip to content

Commit 941dd6c

Browse files
authored
FileUploads: Clean up media when related objects are deleted (#13028)
* FileUploads: Clean up `media` when related objects are deleted * Update dojo/db_migrations/0241_file_upload_cleanup.py * Handle case where the UI will attempt to delete files as well * Update migrations
1 parent 4147b68 commit 941dd6c

File tree

10 files changed

+105
-2
lines changed

10 files changed

+105
-2
lines changed

dojo/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def ready(self):
7676
import dojo.cred.signals
7777
import dojo.endpoint.signals
7878
import dojo.engagement.signals
79+
import dojo.file_uploads.signals
7980
import dojo.finding_group.signals
8081
import dojo.notes.signals
8182
import dojo.product.signals
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from django.db import migrations
2+
from django.db.models import Q
3+
4+
5+
def delete_unreferenced_file_uploads(apps, schema_editor):
6+
FileUpload = apps.get_model("dojo", "FileUpload")
7+
# Filter files that have no relations to Finding, Test, or Engagement
8+
unused_files = FileUpload.objects.filter(
9+
Q(finding__isnull=True) &
10+
Q(test__isnull=True) &
11+
Q(engagement__isnull=True)
12+
).distinct()
13+
# Delete the files from disk first, then delete the FileUpload object
14+
for file_upload in unused_files:
15+
if file_upload.file:
16+
storage = file_upload.file.storage
17+
path = file_upload.file.path
18+
if path and storage.exists(path):
19+
storage.delete(path)
20+
file_upload.delete()
21+
22+
23+
def cannot_turn_back_time(apps, schema_editor):
24+
"""
25+
We cannot possibly return to the original state without knowing
26+
the original value at the time the migration is revoked. Instead
27+
we will do nothing.
28+
"""
29+
pass
30+
31+
32+
class Migration(migrations.Migration):
33+
dependencies = [
34+
("dojo", "0241_remove_system_settings_time_zone"),
35+
]
36+
37+
operations = [
38+
migrations.RunPython(delete_unreferenced_file_uploads, cannot_turn_back_time),
39+
]

dojo/engagement/signals.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.urls import reverse
99
from django.utils.translation import gettext as _
1010

11+
from dojo.file_uploads.helper import delete_related_files
1112
from dojo.models import Engagement, Product
1213
from dojo.notes.helper import delete_related_notes
1314
from dojo.notifications.helper import create_notification
@@ -65,3 +66,4 @@ def engagement_post_delete(sender, instance, using, origin, **kwargs):
6566
def engagement_pre_delete(sender, instance, **kwargs):
6667
with contextlib.suppress(sender.DoesNotExist):
6768
delete_related_notes(instance)
69+
delete_related_files(instance)

dojo/file_uploads/__init__.py

Whitespace-only changes.

dojo/file_uploads/helper.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import logging
2+
3+
logger = logging.getLogger(__name__)
4+
5+
6+
def delete_related_files(obj):
7+
if not hasattr(obj, "files"):
8+
logger.warning(f"Attempted to delete files from object type {type(obj)} without 'files' attribute.")
9+
return
10+
logger.debug(f"Deleting {obj.files.count()} files for {type(obj).__name__} {obj.id}")
11+
obj.files.all().delete()

dojo/file_uploads/signals.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from django.db.models.signals import post_delete, pre_save
2+
from django.dispatch import receiver
3+
4+
from dojo.models import FileUpload
5+
6+
7+
@receiver(post_delete, sender=FileUpload)
8+
def delete_file_on_object_delete(sender, instance, **kwargs):
9+
"""
10+
Deletes file from filesystem when corresponding `FileUpload` is deleted.
11+
Mostly used as a backup in case the FileUpload.delete() function fails
12+
for whatever reason
13+
"""
14+
if instance.file:
15+
storage = instance.file.storage
16+
path = instance.file.path
17+
if path and storage.exists(path):
18+
storage.delete(path)
19+
20+
21+
@receiver(pre_save, sender=FileUpload)
22+
def delete_old_file_on_change(sender, instance, **kwargs):
23+
"""Deletes old file when a new file is uploaded to the same record."""
24+
if not instance.pk:
25+
return # Skip new objects
26+
try:
27+
old_file = FileUpload.objects.get(pk=instance.pk).file
28+
except FileUpload.DoesNotExist:
29+
return
30+
new_file = instance.file
31+
if old_file and old_file != new_file:
32+
storage = old_file.storage
33+
path = old_file.path
34+
if path and storage.exists(path):
35+
storage.delete(path)

dojo/finding/helper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from dojo.celery import app
1515
from dojo.decorators import dojo_async_task, dojo_model_from_id, dojo_model_to_id
1616
from dojo.endpoint.utils import save_endpoints_to_add
17+
from dojo.file_uploads.helper import delete_related_files
1718
from dojo.models import (
1819
Endpoint,
1920
Endpoint_Status,
@@ -413,6 +414,7 @@ def finding_pre_delete(sender, instance, **kwargs):
413414
# https://code.djangoproject.com/ticket/154
414415
instance.found_by.clear()
415416
delete_related_notes(instance)
417+
delete_related_files(instance)
416418

417419

418420
def finding_delete(instance, **kwargs):

dojo/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,14 @@ class FileUpload(models.Model):
790790
title = models.CharField(max_length=100, unique=True)
791791
file = models.FileField(upload_to=UniqueUploadNameProvider("uploaded_files"))
792792

793+
def delete(self, *args, **kwargs):
794+
"""Delete the model and remove the file from storage."""
795+
storage = self.file.storage
796+
path = self.file.path
797+
super().delete(*args, **kwargs)
798+
if path and storage.exists(path):
799+
storage.delete(path)
800+
793801
def copy(self):
794802
copy = _copy_model_util(self)
795803
# Add unique modifier to file name

dojo/test/signals.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.urls import reverse
99
from django.utils.translation import gettext as _
1010

11+
from dojo.file_uploads.helper import delete_related_files
1112
from dojo.models import Engagement, Finding, Product, Test
1213
from dojo.notes.helper import delete_related_notes
1314
from dojo.notifications.helper import create_notification
@@ -57,3 +58,4 @@ def update_found_by_for_findings(sender, instance, **kwargs):
5758
def test_pre_delete(sender, instance, **kwargs):
5859
with contextlib.suppress(sender.DoesNotExist):
5960
delete_related_notes(instance)
61+
delete_related_files(instance)

dojo/views.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from contextlib import suppress
23
from pathlib import Path
34

45
from auditlog.models import LogEntry
@@ -157,7 +158,8 @@ def manage_files(request, oid, obj_type):
157158

158159
for o in files_formset.deleted_objects:
159160
logger.debug("removing file: %s", o.file.name)
160-
(Path(settings.MEDIA_ROOT) / o.file.name).unlink()
161+
with suppress(FileNotFoundError):
162+
(Path(settings.MEDIA_ROOT) / o.file.name).unlink()
161163

162164
for o in files_formset.new_objects:
163165
logger.debug("adding file: %s", o.file.name)
@@ -168,7 +170,8 @@ def manage_files(request, oid, obj_type):
168170
finding__isnull=True)
169171
for o in orphan_files:
170172
logger.debug("purging orphan file: %s", o.file.name)
171-
(Path(settings.MEDIA_ROOT) / o.file.name).unlink()
173+
with suppress(FileNotFoundError):
174+
(Path(settings.MEDIA_ROOT) / o.file.name).unlink()
172175
o.delete()
173176

174177
messages.add_message(

0 commit comments

Comments
 (0)