Merge branch 'collab-bugfixes' into 'main'

Collab bugfixes

See merge request reportcreator/reportcreator!530
This commit is contained in:
Michael Wedl 2024-04-29 07:18:56 +00:00
commit 12bdd2e761
8 changed files with 102 additions and 27 deletions

View File

@ -2,6 +2,7 @@
## Next
* Collaborative editing in project findings and sections
* Collaborative editing: update notes list when import new notes
* Fix slot data items `.length` property undefined `<list-of-figures>`, `<list-of-tables>` and `<table-of-contents>` components

View File

@ -1,13 +1,15 @@
from .import_export import (
export_notes,
export_project_types,
export_projects,
export_templates,
import_notes,
import_project_types,
import_projects,
import_templates,
)
__all__ = [
'export_project_types', 'export_projects', 'export_templates',
'import_project_types', 'import_projects', 'import_templates',
'export_project_types', 'export_projects', 'export_templates', 'export_notes',
'import_project_types', 'import_projects', 'import_templates', 'import_notes',
]

View File

@ -19,7 +19,10 @@ from reportcreator_api.archive.import_export.serializers import (
PentestProjectExportImportSerializer,
ProjectTypeExportImportSerializer,
)
from reportcreator_api.pentests.consumers import send_collab_event_project, send_collab_event_user
from reportcreator_api.pentests.models import (
CollabEvent,
CollabEventType,
FindingTemplate,
PentestFinding,
PentestProject,
@ -27,8 +30,14 @@ from reportcreator_api.pentests.models import (
ProjectNotebookPage,
ProjectType,
ReportSection,
UserNotebookPage,
)
from reportcreator_api.pentests.serializers.notes import (
ProjectNotebookPageSerializer,
ProjectNotebookPageSortListSerializer,
UserNotebookPageSerializer,
UserNotebookPageSortListSerializer,
)
from reportcreator_api.pentests.models.notes import UserNotebookPage
from reportcreator_api.users.models import PentestUser
from reportcreator_api.utils.history import history_context
@ -265,5 +274,41 @@ def import_projects(archive_file):
def import_notes(archive_file, context):
if not context.get('project') and not context.get('user'):
raise ValueError('Either project or user must be provided')
return import_archive(archive_file, serializer_classes=[NotesExportImportSerializer], context=context)
# Import notes to DB
notes = import_archive(archive_file, serializer_classes=[NotesExportImportSerializer], context=context)
# Send collab events
sender_options = {
'related_object': context['project'],
'serializer': ProjectNotebookPageSerializer,
'serializer_sort': ProjectNotebookPageSortListSerializer,
'send_collab_event': send_collab_event_project,
} if context.get('project') else {
'related_object': context['user'],
'serializer': UserNotebookPageSerializer,
'serializer_sort': UserNotebookPageSortListSerializer,
'send_collab_event': send_collab_event_user,
}
# Create events
events = CollabEvent.objects.bulk_create(
CollabEvent(
related_id=sender_options['related_object'].id,
path=f'notes.{n.note_id}',
type=CollabEventType.CREATE,
created=n.created,
version=n.created.timestamp(),
data={
'value': sender_options['serializer'](instance=n).data,
},
) for n in notes
)
for e in events:
sender_options['send_collab_event'](e)
# Sort event
notes_sorted = sender_options['related_object'].notes.select_related('parent').all()
sender_options['serializer_sort'](instance=notes_sorted, context=context).send_collab_event(notes_sorted)
return notes

View File

@ -84,8 +84,14 @@ class WebsocketConsumerBase(AsyncJsonWebsocketConsumer):
await self.close(code=4443)
return
with history_context(history_user=self.scope.get('user')):
return await super().websocket_receive(message)
try:
with history_context(history_user=self.scope.get('user')):
return await super().websocket_receive(message)
except ValidationError as ex:
await self.send_json({
'type': 'error',
'message': ex.message,
})
async def websocket_disconnect(self, message):
try:
@ -288,7 +294,7 @@ class NotesConsumerBase(WebsocketConsumerBase):
.select_for_update(of=['self'], no_key=True) \
.first()
if not note:
return None, None
raise ValidationError('Invalid path: ID not found')
return note, path_parts[2]
@database_sync_to_async
@ -297,8 +303,6 @@ class NotesConsumerBase(WebsocketConsumerBase):
# Validate path and get note
valid_paths = {k for k, f in self.get_serializer().fields.items() if not f.read_only} - {'title', 'text'}
note, key = self.get_note_for_update(path=content.get('path'), valid_paths=valid_paths)
if not note:
return None
# Update in DB
serializer = self.get_serializer(instance=note, data={key: content.get('value')}, partial=True)
@ -325,8 +329,6 @@ class NotesConsumerBase(WebsocketConsumerBase):
if not content.get('updates', []):
raise ValidationError('No updates')
note, key = self.get_note_for_update(path=content.get('path'), valid_paths=['title', 'text'])
if not note:
return None
version = content['version']
# TODO: reject updates for versions that are too old
@ -349,7 +351,7 @@ class NotesConsumerBase(WebsocketConsumerBase):
for u in e.data.get('updates', [])] for e in over_updates])),
)
if not updates:
return None
raise ValidationError('No updates')
# Update in DB
changes = updates[0].changes
@ -585,7 +587,7 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
.select_for_update(of=['self'], no_key=True) \
.first()
if not obj:
return None, None, None
raise ValidationError('Invalid path: ID not found')
# Validate path in top-level or in field definition
if path_parts[2] == 'data':
@ -605,8 +607,8 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
def collab_update_key(self, content):
# Validate path and get section/finding
obj, path, definition = self.get_object_for_update(content.get('path'))
if not obj or (definition and definition.type in [FieldDataType.MARKDOWN, FieldDataType.STRING]):
return None
if definition and definition.type in [FieldDataType.MARKDOWN, FieldDataType.STRING]:
raise ValidationError('collab.update_key is not supported for text fields. Use collab.update_text instead.')
# Update data in DB
if definition:
@ -641,8 +643,8 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
@transaction.atomic()
def collab_update_text(self, content):
obj, path, definition = self.get_object_for_update(content.get('path'))
if not obj or not definition or definition.type not in [FieldDataType.MARKDOWN, FieldDataType.STRING]:
return None
if not definition or definition.type not in [FieldDataType.MARKDOWN, FieldDataType.STRING]:
raise ValidationError('collab.update_text is not supported for non-text fields. Use collab.update_key instead.')
version = content['version']
# TODO: reject updates for versions that are too old
@ -662,7 +664,7 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
for u in e.data.get('updates', [])] for e in over_updates])),
)
if not updates:
return None
raise ValidationError('No updates')
# Update in DB
changes = updates[0].changes
@ -697,8 +699,8 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
@transaction.atomic()
def collab_create(self, content):
obj, path, definition = self.get_object_for_update(content.get('path'))
if not obj or not definition or definition.type != FieldDataType.LIST:
return None
if not definition or definition.type != FieldDataType.LIST:
raise ValidationError('collab.create is only supported for list fields')
# Update DB
updated_data = obj.data
@ -726,16 +728,16 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
@transaction.atomic()
def collab_delete(self, content):
obj, path, definition = self.get_object_for_update(content.get('path'))
if not obj or not definition:
return None
if not definition:
raise ValidationError('collab.delete is only supported for fields')
updated_data = obj.data
lst = get_value_at_path(updated_data, path[1:-1])
if not isinstance(lst, list):
return None
raise ValidationError('collab.delete is only supported for fields')
index = int(path[-1][1:-1] if path[-1].startswith('[') and path[-1].endswith(']') else path[-1])
if not (0 <= index < len(lst)):
return None
raise ValidationError('Invalid list index')
lst.pop(index)
serializer = (ReportSectionSerializer if isinstance(obj, ReportSection) else PentestFindingSerializer)(instance=obj, data={'data': updated_data}, partial=True)
serializer.is_valid(raise_exception=True)

View File

@ -38,6 +38,7 @@ from reportcreator_api.archive.import_export import (
import_templates,
)
from reportcreator_api.archive.import_export.import_export import export_notes, import_notes
from reportcreator_api.pentests.consumers import send_collab_event_project
from reportcreator_api.pentests.customfields.predefined_fields import FINDING_FIELDS_PREDEFINED
from reportcreator_api.pentests.customfields.types import field_definition_to_dict
from reportcreator_api.pentests.models import (
@ -61,6 +62,7 @@ from reportcreator_api.pentests.models import (
UploadedTemplateImage,
UserPublicKey,
)
from reportcreator_api.pentests.models.collab import CollabEvent, CollabEventType
from reportcreator_api.pentests.permissions import (
ArchivedProjectKeyPartPermissions,
IsTemplateEditorOrReadOnly,

View File

@ -11,8 +11,12 @@ from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from reportcreator_api.archive.import_export import export_project_types, export_projects, export_templates
from reportcreator_api.archive.import_export.import_export import export_notes
from reportcreator_api.archive.import_export import (
export_notes,
export_project_types,
export_projects,
export_templates,
)
from reportcreator_api.notifications.models import NotificationSpec, UserNotification
from reportcreator_api.pentests.models import (
FindingTemplate,

View File

@ -10,10 +10,12 @@ from channels.testing import WebsocketCommunicator
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.contrib.auth.models import AnonymousUser
from django.core.files.base import ContentFile
from django.core.serializers.json import DjangoJSONEncoder
from django.urls import reverse
from django.utils.module_loading import import_string
from reportcreator_api.archive.import_export import export_notes
from reportcreator_api.conf.asgi import application
from reportcreator_api.pentests.customfields.utils import (
ensure_defined_structure,
@ -451,6 +453,20 @@ class TestProjectNotesDbSync:
data=[{'id': self.note.note_id, 'order': 1, 'parent': None}])
await self.assert_event({'type': CollabEventType.SORT, 'path': 'notes', 'client_id': None, 'sort': res.data})
async def test_import_sync(self):
def import_notes():
res = self.api_client1.post(
path=reverse('projectnotebookpage-import', kwargs={'project_pk': self.project.id}),
data={'file': ContentFile(content=b''.join(export_notes(self.project)), name='export.tar.gz')},
format='multipart',
)
return res.data
notes_imported = await sync_to_async(import_notes)()
for n in notes_imported:
await self.assert_event({'type': CollabEventType.CREATE, 'path': f'notes.{n["id"]}', 'value': n, 'client_id': None})
await self.assert_event({'type': CollabEventType.SORT, 'path': 'notes', 'client_id': None})
async def test_member_removed_write(self):
await ProjectMemberInfo.objects.filter(project=self.project, user=self.user1).adelete()

View File

@ -130,7 +130,7 @@ export function useCollab<T = any>(storeState: CollabStoreState<T>) {
})
storeState.websocket.addEventListener('close', (event) => {
// Error handling
if (event.code === 4443 || (event.code === 1006 && storeState.connectionState === CollabConnectionState.CONNECTING)) {
if (event.code === 4443) {
storeState.connectionError = { error: event, message: event.reason || 'Permission denied' };
} else if (storeState.connectionState === CollabConnectionState.CONNECTING) {
storeState.connectionError = { error: event, message: event.reason || 'Failed to establish connection' };
@ -232,6 +232,9 @@ export function useCollab<T = any>(storeState: CollabStoreState<T>) {
}),
};
}
} else if (msgData.type === 'error') {
// eslint-disable-next-line no-console
console.error('Received error from websocket:', msgData);
} else if (msgData.type === 'ping') {
// Do nothing
} else {