Skip to content

Commit 4f7872f

Browse files
committed
Archive export and import with email and frontend notifications.
New NotificationCenter component.
1 parent fa2fbfc commit 4f7872f

24 files changed

Lines changed: 1418 additions & 79 deletions

File tree

colander/core/admin.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from colander.core.models import (
44
Actor,
5+
ArchiveExport,
56
Artifact,
67
ArtifactType,
78
BackendCredentials,
@@ -15,12 +16,14 @@
1516
EntityRelation,
1617
Event,
1718
EventType,
19+
FeedTemplate,
1820
Observable,
1921
ObservableRelation,
2022
ObservableType,
2123
PiRogueExperiment,
24+
SubGraph,
2225
Threat,
23-
UploadRequest, SubGraph, FeedTemplate,
26+
UploadRequest, NotificationMessage,
2427
)
2528

2629

@@ -149,3 +152,12 @@ class SubGraphAdmin(admin.ModelAdmin):
149152
class FeedTemplateAdmin(admin.ModelAdmin):
150153
list_display = ('name', 'owner', 'visibility', 'in_error')
151154
admin.site.register(FeedTemplate, FeedTemplateAdmin)
155+
156+
157+
class ArchiveExportAdmin(admin.ModelAdmin):
158+
list_display = ('case', 'type', 'requested_at', 'done_at', 'file')
159+
admin.site.register(ArchiveExport, ArchiveExportAdmin)
160+
161+
class NotificationMessageAdmin(admin.ModelAdmin):
162+
list_display = ('recipient', 'type', 'requested_at', 'processed_at', 'success')
163+
admin.site.register(NotificationMessage, NotificationMessageAdmin)

colander/core/api/views.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,5 +290,10 @@ class ApiDroppedFileViewSet(mixins.CreateModelMixin,
290290

291291
def perform_create(self, serializer):
292292
inst = serializer.save(owner=self.request.user)
293-
CaseContextConsumer.send_message_to_user_consumers(self.request.user, { 'msg': 'New dropped file' })
293+
CaseContextConsumer.send_message_to_user_consumers(
294+
self.request.user, {
295+
'msg': 'A new drop is available',
296+
'detail': inst.filename
297+
}
298+
)
294299
return inst
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import json
2+
from functools import partial
3+
4+
from django.contrib import messages
5+
from django.contrib.auth.decorators import login_required
6+
from django.db import transaction
7+
from django.http import JsonResponse, HttpResponseRedirect, HttpResponseForbidden, HttpResponse, \
8+
StreamingHttpResponse
9+
from django.utils.http import content_disposition_header
10+
from rest_framework.exceptions import ValidationError
11+
12+
from colander.core.archives.exporters import schedule_archive_export
13+
from colander.core.archives.serializers import model_by_super_types_str, serializers_by_model
14+
from colander.core.models import Case, ArchiveExport, Appendix
15+
16+
17+
@login_required
18+
def case_archive_request_view(request, pk):
19+
20+
case = Case.objects.get(pk=pk)
21+
22+
archive_export = ArchiveExport.objects.create(
23+
case=case,
24+
type=Appendix.ExportType.CASE,
25+
)
26+
27+
#archives.schedule_archive_export(archive_export)
28+
transaction.on_commit(partial(schedule_archive_export, archive_export))
29+
30+
messages.info(request, "Archive export requested. You will be notified when done.")
31+
32+
return HttpResponseRedirect(request.META.get('HTTP_REFERER'))
33+
34+
35+
@login_required
36+
def archive_takeout_view(request, pk):
37+
38+
archive_export = ArchiveExport.objects.get(pk=pk)
39+
case = archive_export.case
40+
if not case.can_contribute(request.user):
41+
return HttpResponseForbidden()
42+
43+
if archive_export.is_pending:
44+
response = HttpResponse(status=503)
45+
response.headers["Retry-After"] = "120" # 2 minutes
46+
return response
47+
48+
response = StreamingHttpResponse(archive_export.file, content_type='application/zip')
49+
response['Content-Disposition'] = content_disposition_header(
50+
True,
51+
archive_export.filename
52+
)
53+
54+
return response
55+
56+
57+
@login_required
58+
def archives_check_uuid_view(request, super_type, uuid):
59+
if super_type not in model_by_super_types_str:
60+
return JsonResponse({'message': f'{super_type} not supported'}, status=400)
61+
model_class = model_by_super_types_str[super_type]
62+
if model_class.objects.filter(pk=uuid).exists():
63+
return JsonResponse({'message': 'uuid exists'}, status=200)
64+
else:
65+
return JsonResponse({'message': 'uuid does not exist'}, status=404)
66+
67+
68+
@login_required
69+
def archives_create_entity_view(request, super_type):
70+
if super_type not in model_by_super_types_str:
71+
return JsonResponse({'message': f'{super_type} not supported'}, status=400)
72+
73+
model_class = model_by_super_types_str[super_type]
74+
serializer_class = serializers_by_model[model_class]
75+
76+
try:
77+
payload = json.loads(request.body.decode('utf-8'))
78+
serializer = serializer_class(data=payload, context={'request': request})
79+
serializer.is_valid(raise_exception=True)
80+
instance = serializer.save(owner=request.user)
81+
return JsonResponse(serializer_class(instance).data)
82+
except ValidationError as ve:
83+
return JsonResponse({'message': ve.detail}, status=400)
84+
except Exception as e:
85+
return JsonResponse({'message': f'Unable to create entity: {e}'}, status=400)
86+
87+
88+
@login_required
89+
def archives_remap_entity_view(request, super_type, uuid):
90+
if super_type not in model_by_super_types_str:
91+
return JsonResponse({'message': f'{super_type} not supported'}, status=400)
92+
93+
model_class = model_by_super_types_str[super_type]
94+
serializer_class = serializers_by_model[model_class]
95+
96+
try:
97+
instance = model_class.objects.get(pk=uuid)
98+
payload = json.loads(request.body.decode('utf-8'))
99+
serializer = serializer_class(instance, data=payload, partial=True, context={'request': request})
100+
serializer.is_valid(raise_exception=True)
101+
instance = serializer.save()
102+
return JsonResponse(serializer_class(instance).data)
103+
except ValidationError as ve:
104+
return JsonResponse({'message': ve.detail}, status=400)
105+
except Exception as e:
106+
return JsonResponse({'message': f'Unable to remap entity: {e}'}, status=400)
107+
108+
109+
@login_required
110+
def archives_attach_entity_view(request, super_type, uuid):
111+
if super_type not in model_by_super_types_str:
112+
return JsonResponse({'message': f'{super_type} not supported'}, status=400)
113+
114+
model_class = model_by_super_types_str[super_type]
115+
serializer_class = serializers_by_model[model_class]
116+
117+
try:
118+
instance = model_class.objects.get(pk=uuid)
119+
payload = request.FILES
120+
serializer = serializer_class(instance, data=payload, partial=True, context={'request': request})
121+
serializer.is_valid(raise_exception=True)
122+
instance = serializer.save()
123+
return JsonResponse(serializer_class(instance).data)
124+
except ValidationError as ve:
125+
return JsonResponse({'message': ve.detail}, status=400)
126+
except Exception as e:
127+
return JsonResponse({'message': f'Unable to remap entity: {e}'}, status=400)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
2+
3+
import datetime
4+
5+
from django.core.files.base import ContentFile
6+
from django.db.models.functions import Now
7+
from django_q.models import Schedule
8+
from django_q.tasks import async_task
9+
10+
from colander.core.archives.exporters.case import CaseArchiveExporter
11+
from colander.core.models import ArchiveExport, Appendix
12+
from colander.core.notifications import notify_case_archive_done
13+
from colander.websocket.consumers import CaseContextConsumer
14+
15+
MISSING_ARCHIVE_EXPORTER_SCHEDULE_TASK_NAME = 'missing_archive_exporter'
16+
17+
18+
def schedule_archive_export(archive_export:ArchiveExport):
19+
hook=f'{_archive_export_end.__module__}.{_archive_export_end.__qualname__}'
20+
async_task(_process_archive_export, str(archive_export.id), hook=hook)
21+
_ensure_missing_archives_task_exist()
22+
23+
24+
def _process_archive_export(archive_export_id):
25+
print(f'Processing archive export: {archive_export_id}')
26+
27+
archive_export = ArchiveExport.objects.get(pk=archive_export_id)
28+
if archive_export.type == Appendix.ExportType.CASE:
29+
cae = CaseArchiveExporter(archive_export.case)
30+
ct = ContentFile(cae.export(pretty=True), 'dummy.zip')
31+
archive_export.file = ct
32+
archive_export.save()
33+
34+
35+
def _archive_export_end(task):
36+
archive_export_id = task.args[0]
37+
38+
archive_export = ArchiveExport.objects.get(pk=archive_export_id)
39+
if task.success:
40+
archive_export.done_at = Now()
41+
archive_export.save()
42+
# Send notifications ...
43+
CaseContextConsumer.send_message_to_user_consumers(archive_export.case.owner, {
44+
'msg': 'A new archive is available',
45+
'detail': archive_export.filename,
46+
})
47+
notify_case_archive_done(archive_export)
48+
49+
print("archive_export_done", archive_export_id, task.result)
50+
51+
52+
def _ensure_missing_archives_task_exist():
53+
func=f'{_proceed_missing_archives.__module__}.{_proceed_missing_archives.__qualname__}'
54+
print('scheduled func to call:', func)
55+
if Schedule.objects.filter(func=func).exists(): return
56+
print('creating scheduled func to call:', func)
57+
Schedule.objects.create(func=func, schedule_type='H', name=MISSING_ARCHIVE_EXPORTER_SCHEDULE_TASK_NAME)
58+
59+
60+
def _proceed_missing_archives():
61+
unprocessed_archive_exports = ArchiveExport.objects.filter(
62+
done_at__isnull=True,
63+
requested_at__lt=Now() - datetime.timedelta(hours=1)
64+
).order_by('-requested_at').all()
65+
66+
for archive_export_to_process in unprocessed_archive_exports:
67+
schedule_archive_export(archive_export_to_process)
68+
69+
unprocessed_archive_exports_count = ArchiveExport.objects.filter(
70+
done_at__isnull=True,
71+
).count()
72+
73+
if unprocessed_archive_exports_count > 0:
74+
return
75+
76+
# unschedule 'missing_archive_exporter' task
77+
func=f'{_proceed_missing_archives.__module__}.{_proceed_missing_archives.__qualname__}'
78+
if Schedule.objects.filter(func=func).exists():
79+
print('unscheduling func:', func)
80+
Schedule.objects.get(func=func).delete()
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import datetime
2+
import io
3+
import json
4+
import zipfile
5+
from django.core.serializers.json import DjangoJSONEncoder
6+
7+
from colander.core.archives.exporters.utils import Buffer
8+
from colander.core.archives.serializers import serializers_by_model
9+
from colander.core.models import Case, SubGraph
10+
11+
12+
class CaseArchiveExporter:
13+
_case: Case
14+
15+
def __init__(self, case: Case):
16+
self._case = case
17+
self._entities = case.entities
18+
self._entities.extend(SubGraph.objects.filter(case=self._case).all())
19+
self._relations = case.relations
20+
print('all entities', self._entities)
21+
22+
def export(self, pretty:bool=False):
23+
buffer = io.BytesIO()
24+
for buf in self.async_export(pretty=pretty):
25+
buffer.write(buf)
26+
buffer.seek(0)
27+
return buffer.getvalue()
28+
29+
def async_export(self, pretty:bool=False):
30+
manifest = {
31+
'export_date': datetime.datetime.now(),
32+
'files': {}
33+
}
34+
buffer = Buffer()
35+
with zipfile.ZipFile(buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
36+
serializer = serializers_by_model.get(Case, None)
37+
json_content = json.dumps( serializer(self._case, many=False).data, cls=DjangoJSONEncoder, indent=2 if pretty else None )
38+
39+
fname = "data.json"
40+
archive.writestr("data.json", json_content)
41+
yield buffer.take()
42+
manifest['files'][fname] = 'md5sum'
43+
44+
for entity in self._entities:
45+
entity_model = entity.__class__
46+
serializer = serializers_by_model.get(entity_model, None)
47+
if serializer:
48+
data = serializer(entity, many=False).data
49+
json_content = json.dumps( data, cls=DjangoJSONEncoder, indent=2 if pretty else None )
50+
fname = f"{str(entity_model.__name__)}/{str(entity.id)}/data.json"
51+
archive.writestr(fname, json_content)
52+
yield buffer.take()
53+
manifest['files'][fname] = 'md5sum'
54+
55+
if hasattr(entity, 'thumbnail'):
56+
if entity.thumbnail:
57+
thumbnail_data = entity.thumbnail.file.read()
58+
fname = f"{str(entity_model.__name__)}/{str(entity.id)}/thumbnail.png"
59+
archive.writestr(fname, thumbnail_data)
60+
yield buffer.take()
61+
manifest['files'][fname] = 'md5sum'
62+
63+
if hasattr(entity, 'file'):
64+
if entity.file:
65+
file_data = entity.file.file.read()
66+
fname = f"{str(entity_model.__name__)}/{str(entity.id)}/file{str(entity.extension)}"
67+
archive.writestr(fname, file_data)
68+
yield buffer.take()
69+
manifest['files'][fname] = 'md5sum'
70+
71+
for relation in self._relations:
72+
if relation.immutable: continue
73+
entity_model = relation.__class__
74+
serializer = serializers_by_model.get(entity_model, None)
75+
if serializer:
76+
data = serializer(relation, many=False).data
77+
json_content = json.dumps( data, cls=DjangoJSONEncoder, indent=2 if pretty else None )
78+
fname = f"{str(entity_model.__name__)}/{str(relation.id)}/data.json"
79+
archive.writestr(fname, json_content)
80+
yield buffer.take()
81+
manifest['files'][fname] = 'md5sum'
82+
83+
json_content = json.dumps( manifest, cls=DjangoJSONEncoder, indent=2 if pretty else None )
84+
archive.writestr("manifest.json", json_content)
85+
yield buffer.take()
86+
87+
yield buffer.end()
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
class Buffer:
2+
# A lightweight buffer to use with zip library
3+
def __init__(self):
4+
self.buf = bytearray()
5+
6+
def write(self, data):
7+
self.buf.extend(data)
8+
return len(data)
9+
10+
def flush(self):
11+
pass
12+
13+
def take(self):
14+
buf = self.buf
15+
self.buf = bytearray()
16+
return bytes(buf)
17+
18+
def end(self):
19+
buf = self.buf
20+
self.buf = None
21+
return bytes(buf)

0 commit comments

Comments
 (0)