253 lines
9.6 KiB
Python
253 lines
9.6 KiB
Python
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'),
|
|
]
|