from django.http import HttpResponse from django.urls import path from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from authentication.signature_auth import SignatureAuthentication from toolshed.offlinedata import ( inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv, import_locations, import_friends, import_inventory, profile_data, profile_picture_files, settings_data, import_profile, import_settings, delete_user_data, delete_user_account, ) def local_user_or_none(identity): """Resolve the local ToolshedUser associated with an authenticated KnownIdentity, if any. Returns None if the identity belongs to an external/remote user with no local account. """ if identity is None: return None return identity.user.first() def user_data(user): import io import json import zipfile zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file: zip_file.writestr('profile.json', json.dumps(profile_data(user))) zip_file.writestr('settings.json', json.dumps(settings_data(user))) inventory_csv = b''.join(rows_to_csv(inventory_rows(user))) zip_file.writestr('inventory.csv', inventory_csv) friends_csv = b''.join(rows_to_csv(friend_rows(user))) zip_file.writestr('friends.csv', friends_csv) locations_csv = b''.join(rows_to_csv(location_rows(user))) zip_file.writestr('locations.csv', locations_csv) written_files = set() for arcname, data in profile_picture_files(user): zip_file.writestr(arcname, data) written_files.add(arcname) for arcname, data in inventory_files(user): if arcname in written_files: continue zip_file.writestr(arcname, data) written_files.add(arcname) return zip_buffer.getvalue() def parse_user_data(data): import io import zipfile with zipfile.ZipFile(io.BytesIO(data), "r") as zip_file: for file_name in zip_file.namelist(): yield file_name, zip_file.read(file_name) def import_files(zip_file): """Fault-tolerant extraction of the 'files/' subfolder into File objects. Returns a dict mapping the zip arcname to the created/existing File instance. Entries that fail to read or save are silently skipped so a single corrupt attachment doesn't abort the import. """ from hashlib import sha256 import mimetypes from django.core.files.base import ContentFile from django.db import transaction from files.models import File result = {} for name in zip_file.namelist(): if not name.startswith('files/') or name == 'files/': continue try: with transaction.atomic(): data = zip_file.read(name) content_hash = sha256(data).hexdigest() mime_type, _ = mimetypes.guess_type(name) file_obj = File.objects.filter(hash=content_hash).first() if file_obj is None: file_obj = File.objects.create( file=ContentFile(data, content_hash), mime_type=mime_type or 'application/octet-stream', hash=content_hash, ) result[name] = file_obj except Exception as error: print(f'Skipping file "{name}" during import: {error}') return result def import_user_data(user, data): """Fault-tolerant import of an export zip produced by `user_data()`. Any of 'profile.json', 'settings.json', 'inventory.csv', 'friends.csv', 'locations.csv' or the 'files/' subfolder may be missing; whatever is present is imported and everything else is silently skipped. Each section is imported inside its own transaction savepoint (`transaction.atomic()`), so a DB-level failure in one section (e.g. a profile.json whose email collides with another account) can't leave the connection in a broken/aborted-transaction state that would otherwise silently take down every subsequent section (including the inventory items and their properties) with an opaque "current transaction is aborted" error. """ import io import zipfile from django.db import transaction summary = {'profile': False, 'settings': 0, 'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0, 'errors': []} with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file: names = set(zip_file.namelist()) available_files = import_files(zip_file) summary['files'] = len(available_files) if 'profile.json' in names: try: with transaction.atomic(): summary['profile'] = import_profile(user, zip_file.read('profile.json'), available_files) except Exception as error: summary['errors'].append(f'Could not import profile.json: {error}') if 'settings.json' in names: try: with transaction.atomic(): summary['settings'] = import_settings(user, zip_file.read('settings.json')) except Exception as error: summary['errors'].append(f'Could not import settings.json: {error}') if 'locations.csv' in names: try: with transaction.atomic(): summary['locations'] = import_locations(user, zip_file.read('locations.csv')) except Exception as error: summary['errors'].append(f'Could not import locations.csv: {error}') if 'friends.csv' in names: try: with transaction.atomic(): summary['friends'] = import_friends(user, zip_file.read('friends.csv')) except Exception as error: summary['errors'].append(f'Could not import friends.csv: {error}') if 'inventory.csv' in names: try: with transaction.atomic(): summary['inventory_items'], inventory_errors = import_inventory( user, zip_file.read('inventory.csv'), available_files) summary['errors'].extend(inventory_errors) except Exception as error: summary['errors'].append(f'Could not import inventory.csv: {error}') return summary def _extract_zip_bytes(zip_payload): """Normalize the incoming 'zip' request payload (uploaded file, base64 string, or raw bytes).""" import base64 if hasattr(zip_payload, 'read'): return zip_payload.read() if isinstance(zip_payload, str): try: return base64.b64decode(zip_payload, validate=True) except Exception: return zip_payload.encode('utf-8') return zip_payload @api_view(['POST']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def import_data(request, format=None): local_user = local_user_or_none(request.user) if local_user is None: return Response({'detail': 'This endpoint is only available to local users'}, status=403) zip_payload = request.data.get('zip') if not zip_payload: return Response(status=400) try: zip_bytes = _extract_zip_bytes(zip_payload) summary = import_user_data(local_user, zip_bytes) except Exception as error: return Response({'detail': f'Could not read zip file: {error}'}, status=400) return Response(summary, status=200) @api_view(['GET']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def export_data(request, format=None): local_user = local_user_or_none(request.user) if local_user is None: return Response({'detail': 'This endpoint is only available to local users'}, status=403) return HttpResponse(user_data(local_user), content_type='application/zip', status=200) @api_view(['DELETE']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def delete_data(request, format=None): """Wipe all of the local user's data (everything included in the export), but keep the account. This is *not* account deletion/closure - the user stays logged in and can keep using the account (with all their data reset to a blank slate) afterwards. """ local_user = local_user_or_none(request.user) if local_user is None: return Response({'detail': 'This endpoint is only available to local users'}, status=403) summary = delete_user_data(local_user) return Response(summary, status=200) @api_view(['DELETE']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def delete_account(request, format=None): """Permanently close the local user's account, after wiping all of its data. Unlike `delete_data()`, this also removes the account itself - the user can no longer log in afterwards. Their public identity is kept so remote friends/history referencing it stay intact, but the local ToolshedUser row is gone. """ local_user = local_user_or_none(request.user) if local_user is None: return Response({'detail': 'This endpoint is only available to local users'}, status=403) summary = delete_user_account(local_user) return Response(summary, status=200) urlpatterns = [ path('export/', export_data, name='export_data'), path('import/', import_data, name='import_data'), path('account_data/', delete_data, name='delete_data'), path('account/', delete_account, name='delete_account'), ]