toolshed/backend/toolshed/api/offlinedata.py
2026-08-01 12:26:18 +02:00

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'),
]