test
This commit is contained in:
parent
8034138cc7
commit
6e1ca588b7
@ -35,12 +35,33 @@ def ensure_space(user):
|
|||||||
def _make_numbered_space(parent, **fields):
|
def _make_numbered_space(parent, **fields):
|
||||||
if len(parent.relative_path) + 1 + 24 > 700:
|
if len(parent.relative_path) + 1 + 24 > 700:
|
||||||
raise ValueError('Hiérarchie trop profonde.')
|
raise ValueError('Hiérarchie trop profonde.')
|
||||||
|
with transaction.atomic():
|
||||||
obj = DriveSpace.objects.create(parent=parent, relative_path=f'pending/{secrets.token_hex(10)}', **fields)
|
obj = DriveSpace.objects.create(parent=parent, relative_path=f'pending/{secrets.token_hex(10)}', **fields)
|
||||||
obj.directory_name = f'{obj.pk - 1:04d}'
|
number = obj.pk - 1
|
||||||
obj.relative_path = '/'.join(part for part in (parent.relative_path, obj.directory_name) if part)
|
directory = Path(settings.DRIVE_STORAGE_ROOT) / parent.relative_path
|
||||||
|
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
# With real file names, a user may already have created "0001" as a
|
||||||
|
# folder/file. Do NOT reuse it for another person's space.
|
||||||
|
for _ in range(100000):
|
||||||
|
candidate = f'{number:04d}'
|
||||||
|
target = directory / candidate
|
||||||
|
if DriveSpace.objects.filter(directory_name=candidate).exists() or target.exists() or target.is_symlink():
|
||||||
|
number += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
target.mkdir(mode=0o700)
|
||||||
|
except FileExistsError:
|
||||||
|
number += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj.directory_name = candidate
|
||||||
|
obj.relative_path = '/'.join(part for part in (parent.relative_path, candidate) if part)
|
||||||
obj.save(update_fields=['directory_name', 'relative_path'])
|
obj.save(update_fields=['directory_name', 'relative_path'])
|
||||||
(Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True)
|
except Exception:
|
||||||
|
target.rmdir()
|
||||||
|
raise
|
||||||
return obj
|
return obj
|
||||||
|
raise ValueError('Aucun numéro de répertoire disponible pour ce nouvel espace.')
|
||||||
|
|
||||||
|
|
||||||
def create_space(user, parent, display_name, **permissions):
|
def create_space(user, parent, display_name, **permissions):
|
||||||
@ -161,9 +182,6 @@ def storage_path(item):
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def new_storage_location(space, key):
|
|
||||||
return '/'.join(part for part in (space.relative_path, key[:2], key) if part)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_token(token):
|
def hash_token(token):
|
||||||
return hashlib.sha256(token.encode('utf-8')).hexdigest()
|
return hashlib.sha256(token.encode('utf-8')).hexdigest()
|
||||||
|
|||||||
114
backend/drive/storage_layout.py
Normal file
114
backend/drive/storage_layout.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
"""Store DriveItems as normal filenames beneath their numbered DriveSpace.
|
||||||
|
|
||||||
|
All relative paths are inside DRIVE_STORAGE_ROOT. The DB hierarchy is authoritative,
|
||||||
|
so a physical folder can be rebuilt from its current DriveItem ancestors.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from .models import DriveItem
|
||||||
|
|
||||||
|
|
||||||
|
class NameConflict(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def folder_location(space, parent):
|
||||||
|
parts = []
|
||||||
|
seen = set()
|
||||||
|
while parent is not None:
|
||||||
|
if parent.pk in seen or not parent.is_folder or parent.space_id != space.pk:
|
||||||
|
raise ValueError('Hiérarchie de dossiers invalide.')
|
||||||
|
if parent.name in ('.', '..') or '/' in parent.name or '\\' in parent.name:
|
||||||
|
raise ValueError('Nom de dossier invalide.')
|
||||||
|
seen.add(parent.pk)
|
||||||
|
parts.append(parent.name)
|
||||||
|
parent = parent.parent
|
||||||
|
return '/'.join(part for part in (space.relative_path, *reversed(parts)) if part)
|
||||||
|
|
||||||
|
|
||||||
|
def file_location(space, parent, name):
|
||||||
|
return '/'.join(part for part in (folder_location(space, parent), name) if part)
|
||||||
|
|
||||||
|
|
||||||
|
def disk_path(location):
|
||||||
|
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
|
||||||
|
path = root
|
||||||
|
for part in Path(location).parts:
|
||||||
|
if part in ('', '.', '..') or part.startswith('/') or '\\' in part:
|
||||||
|
raise ValueError('Chemin de stockage invalide.')
|
||||||
|
path = path / part
|
||||||
|
if path.is_symlink():
|
||||||
|
raise ValueError('Lien symbolique interdit dans le stockage.')
|
||||||
|
if not path.resolve().is_relative_to(root):
|
||||||
|
raise ValueError('Chemin de stockage hors du répertoire autorisé.')
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def assert_available(space, parent, name, exclude=None):
|
||||||
|
siblings = DriveItem.objects.filter(space=space, parent=parent, name=name)
|
||||||
|
if exclude is not None:
|
||||||
|
siblings = siblings.exclude(pk=exclude)
|
||||||
|
if siblings.exists():
|
||||||
|
raise NameConflict('Un élément portant ce nom existe déjà à cet emplacement (y compris dans la corbeille).')
|
||||||
|
target = disk_path(file_location(space, parent, name))
|
||||||
|
if target.exists() or target.is_symlink():
|
||||||
|
raise NameConflict('Un fichier ou un dossier portant ce nom existe déjà sur le disque.')
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_parent(space, parent):
|
||||||
|
location = folder_location(space, parent)
|
||||||
|
destination = disk_path(location)
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def relocate_item(item, new_parent, new_name):
|
||||||
|
"""Rename/move a file or folder and its descendants on disk and in SQL.
|
||||||
|
|
||||||
|
The on-disk move is reversed if the database transaction fails. The caller
|
||||||
|
must ensure authorization and that new_parent is not a descendant of item.
|
||||||
|
"""
|
||||||
|
old = (file_location(item.space, item.parent, item.name) if item.is_folder
|
||||||
|
else item.storage_location or f'{item.storage_key[:2]}/{item.storage_key}')
|
||||||
|
new = file_location(item.space, new_parent, new_name)
|
||||||
|
source = disk_path(old)
|
||||||
|
target = assert_available(item.space, new_parent, new_name, exclude=item.pk)
|
||||||
|
prepare_parent(item.space, new_parent)
|
||||||
|
if not source.exists():
|
||||||
|
if not item.is_folder:
|
||||||
|
raise FileNotFoundError(f'Fichier source introuvable : {old}')
|
||||||
|
# Historical logical folders had no corresponding physical directory.
|
||||||
|
source.mkdir(parents=True, exist_ok=True)
|
||||||
|
if (not source.is_dir()) if item.is_folder else (not source.is_file()):
|
||||||
|
raise ValueError('Type de fichier incorrect sur le disque.')
|
||||||
|
moved = False
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# Two distinct paths are guaranteed by caller's early unchanged check.
|
||||||
|
source.rename(target)
|
||||||
|
moved = True
|
||||||
|
if item.is_folder:
|
||||||
|
prefix = old.rstrip('/') + '/'
|
||||||
|
affected = list(DriveItem.objects.filter(
|
||||||
|
space=item.space, storage_location__startswith=prefix
|
||||||
|
).only('pk', 'storage_location'))
|
||||||
|
for child in affected:
|
||||||
|
child.storage_location = new + '/' + child.storage_location[len(prefix):]
|
||||||
|
if affected:
|
||||||
|
DriveItem.objects.bulk_update(affected, ['storage_location'], batch_size=500)
|
||||||
|
else:
|
||||||
|
item.storage_location = new
|
||||||
|
item.name = new_name
|
||||||
|
item.parent = new_parent
|
||||||
|
item.full_clean()
|
||||||
|
item.save()
|
||||||
|
except Exception:
|
||||||
|
if moved:
|
||||||
|
# Do not replace a path created by another operation in the meantime.
|
||||||
|
if target.exists() and not source.exists():
|
||||||
|
target.rename(source)
|
||||||
|
raise
|
||||||
@ -11,6 +11,7 @@ from .models import DriveItem, DriveSpace
|
|||||||
from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space
|
from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space
|
||||||
from .quotas import quota_state
|
from .quotas import quota_state
|
||||||
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, descendant_files
|
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, descendant_files
|
||||||
|
from .storage_layout import disk_path, file_location, assert_available, prepare_parent, relocate_item, NameConflict
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@ -87,8 +88,21 @@ def create_folder(request):
|
|||||||
parent=target_parent(request.data.get('parent'),request.user,space)
|
parent=target_parent(request.data.get('parent'),request.user,space)
|
||||||
except ValueError as ex:return error(str(ex))
|
except ValueError as ex:return error(str(ex))
|
||||||
except PermissionError as ex:return error(str(ex),403)
|
except PermissionError as ex:return error(str(ex),403)
|
||||||
|
with transaction.atomic():
|
||||||
|
try:
|
||||||
|
destination=assert_available(space,parent,name)
|
||||||
|
prepare_parent(space,parent)
|
||||||
|
destination.mkdir(mode=0o700)
|
||||||
|
except (NameConflict,FileExistsError) as exc:
|
||||||
|
return error(str(exc),409)
|
||||||
|
except OSError as exc:
|
||||||
|
return error(f'Création impossible : {exc}')
|
||||||
folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True)
|
folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True)
|
||||||
|
try:
|
||||||
folder.full_clean();folder.save()
|
folder.full_clean();folder.save()
|
||||||
|
except Exception:
|
||||||
|
destination.rmdir()
|
||||||
|
raise
|
||||||
return Response(item_info(folder,request.user),status=201)
|
return Response(item_info(folder,request.user),status=201)
|
||||||
|
|
||||||
|
|
||||||
@ -98,22 +112,26 @@ def update_item(request,item_id):
|
|||||||
if not readable(item,request.user) or not writable(item,request.user):
|
if not readable(item,request.user) or not writable(item,request.user):
|
||||||
return error('Modification interdite.',403)
|
return error('Modification interdite.',403)
|
||||||
data=request.data
|
data=request.data
|
||||||
if 'name' in data:
|
try:
|
||||||
try:item.name=valid_name(data['name'])
|
new_name=valid_name(data['name']) if 'name' in data else item.name
|
||||||
except ValueError as ex:return error(str(ex))
|
new_parent=(target_parent(data['parent'],request.user,item.space)
|
||||||
if 'is_shared' in data:return error('Le partage global est désactivé : utilise les espaces et invitations.')
|
if 'parent' in data else item.parent)
|
||||||
if 'parent' in data:
|
except ValueError as exc:return error(str(exc))
|
||||||
try:new_parent=target_parent(data['parent'],request.user,item.space)
|
except PermissionError as exc:return error(str(exc),403)
|
||||||
except ValueError as ex:return error(str(ex))
|
if 'is_shared' in data:
|
||||||
except PermissionError as ex:return error(str(ex),403)
|
return error('Le partage global est désactivé : utilise les espaces et invitations.')
|
||||||
if new_parent and (new_parent.id==item.id or item.id in [n.id for n in chain(new_parent)]):
|
if new_parent and (new_parent.pk==item.pk or item.pk in [n.pk for n in chain(new_parent)]):
|
||||||
return error('Déplacement impossible dans un sous-dossier de cet élément.')
|
return error('Déplacement impossible dans un sous-dossier de cet élément.')
|
||||||
item.parent=new_parent
|
if new_name==item.name and (new_parent.pk if new_parent else None)==item.parent_id:
|
||||||
try:item.full_clean()
|
|
||||||
except ValidationError as ex:return error(str(ex))
|
|
||||||
item.save()
|
|
||||||
return Response(item_info(item,request.user))
|
return Response(item_info(item,request.user))
|
||||||
|
|
||||||
|
try:
|
||||||
|
relocate_item(item,new_parent,new_name)
|
||||||
|
except (NameConflict,FileExistsError) as exc:
|
||||||
|
return error(str(exc),409)
|
||||||
|
except (OSError,ValueError,ValidationError) as exc:
|
||||||
|
return error(f'Déplacement impossible : {exc}',409)
|
||||||
|
return Response(item_info(item,request.user))
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def trash_item(request,item_id):
|
def trash_item(request,item_id):
|
||||||
@ -130,7 +148,10 @@ def restore_item(request,item_id):
|
|||||||
if not writable(item,request.user):return error('Accès refusé.',403)
|
if not writable(item,request.user):return error('Accès refusé.',403)
|
||||||
if not item.trashed_at:return error('Cet élément n’est pas dans la corbeille.')
|
if not item.trashed_at:return error('Cet élément n’est pas dans la corbeille.')
|
||||||
if item.parent and any(n.trashed_at for n in chain(item.parent)):
|
if item.parent and any(n.trashed_at for n in chain(item.parent)):
|
||||||
item.parent=None
|
try:
|
||||||
|
relocate_item(item,None,item.name)
|
||||||
|
except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc:
|
||||||
|
return error(f'Restauration impossible : {exc}',409)
|
||||||
item.trashed_at=None;item.save()
|
item.trashed_at=None;item.save()
|
||||||
return Response(item_info(item,request.user))
|
return Response(item_info(item,request.user))
|
||||||
|
|
||||||
@ -141,6 +162,7 @@ def delete_item(request,item_id):
|
|||||||
if not writable(item,request.user):return error('Accès refusé.',403)
|
if not writable(item,request.user):return error('Accès refusé.',403)
|
||||||
if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.')
|
if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.')
|
||||||
files=descendant_files(item)
|
files=descendant_files(item)
|
||||||
|
folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
item.delete()
|
item.delete()
|
||||||
def remove_files():
|
def remove_files():
|
||||||
@ -148,5 +170,15 @@ def delete_item(request,item_id):
|
|||||||
for key in files:
|
for key in files:
|
||||||
path=(root/key).resolve()
|
path=(root/key).resolve()
|
||||||
if path.is_relative_to(root):path.unlink(missing_ok=True)
|
if path.is_relative_to(root):path.unlink(missing_ok=True)
|
||||||
|
if folder_rel:
|
||||||
|
# Remove only empty folders. Untracked data is never deleted.
|
||||||
|
base=disk_path(folder_rel)
|
||||||
|
if base.is_dir():
|
||||||
|
for candidate in sorted(base.rglob('*'),key=lambda p:len(p.parts),reverse=True):
|
||||||
|
if candidate.is_dir() and not candidate.is_symlink():
|
||||||
|
try:candidate.rmdir()
|
||||||
|
except OSError:pass
|
||||||
|
try:base.rmdir()
|
||||||
|
except OSError:pass
|
||||||
transaction.on_commit(remove_files)
|
transaction.on_commit(remove_files)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"""Regular and resumable upload endpoints (disk-backed)."""
|
"""Regular and resumable upload endpoints (disk-backed)."""
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -11,7 +10,8 @@ from rest_framework.parsers import MultiPartParser, FormParser
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import DriveItem, UploadSession
|
from .models import DriveItem, UploadSession
|
||||||
from .services import error, item_info, target_parent, valid_name, readable, writable
|
from .services import error, item_info, target_parent, valid_name, readable, writable
|
||||||
from .spaces import resolve_space, can_write_space, new_storage_location
|
from .spaces import resolve_space, can_write_space
|
||||||
|
from .storage_layout import file_location, disk_path, assert_available, prepare_parent, NameConflict
|
||||||
from .quotas import QuotaExceeded, locked_quota_tree, assert_space_capacity
|
from .quotas import QuotaExceeded, locked_quota_tree, assert_space_capacity
|
||||||
|
|
||||||
|
|
||||||
@ -39,13 +39,13 @@ def upload(request):
|
|||||||
for f in incoming:
|
for f in incoming:
|
||||||
name=valid_name(f.name)
|
name=valid_name(f.name)
|
||||||
key=str(uuid.uuid4())
|
key=str(uuid.uuid4())
|
||||||
location=new_storage_location(space,key)
|
location=file_location(space,parent,name)
|
||||||
dst=Path(settings.DRIVE_STORAGE_ROOT)/location
|
dst=assert_available(space,parent,name)
|
||||||
dst.parent.mkdir(parents=True,exist_ok=True)
|
prepare_parent(space,parent)
|
||||||
with open(dst,'xb') as out:
|
with open(dst,'xb') as out:
|
||||||
|
saved.append(dst) # Cleanup even when a stream fails halfway.
|
||||||
for block in f.chunks(chunk_size=2*1024*1024):
|
for block in f.chunks(chunk_size=2*1024*1024):
|
||||||
out.write(block)
|
out.write(block)
|
||||||
saved.append(dst)
|
|
||||||
mime=mimetypes.guess_type(name)[0] or 'application/octet-stream'
|
mime=mimetypes.guess_type(name)[0] or 'application/octet-stream'
|
||||||
node=DriveItem.objects.create(owner=request.user,space=space,parent=parent,name=name,
|
node=DriveItem.objects.create(owner=request.user,space=space,parent=parent,name=name,
|
||||||
storage_key=key,storage_location=location,size=f.size,mime_type=mime)
|
storage_key=key,storage_location=location,size=f.size,mime_type=mime)
|
||||||
@ -54,7 +54,7 @@ def upload(request):
|
|||||||
for entry in saved:
|
for entry in saved:
|
||||||
path=entry[0] if isinstance(entry,tuple) else entry
|
path=entry[0] if isinstance(entry,tuple) else entry
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
return error(str(ex),413) if isinstance(ex,QuotaExceeded) else error(f'Import impossible : {ex}')
|
return error(str(ex),413) if isinstance(ex,QuotaExceeded) else error(str(ex),409) if isinstance(ex,(NameConflict,FileExistsError)) else error(f'Import impossible : {ex}')
|
||||||
except Exception:
|
except Exception:
|
||||||
for entry in saved:
|
for entry in saved:
|
||||||
path=entry[0] if isinstance(entry,tuple) else entry
|
path=entry[0] if isinstance(entry,tuple) else entry
|
||||||
@ -74,6 +74,9 @@ def upload_start(request):
|
|||||||
except (TypeError,ValueError) as ex:return error(str(ex))
|
except (TypeError,ValueError) as ex:return error(str(ex))
|
||||||
except PermissionError as ex:return error(str(ex),403)
|
except PermissionError as ex:return error(str(ex),403)
|
||||||
if size<0 or size>settings.DRIVE_MAX_FILE_SIZE:return error('Taille de fichier non autorisée.',413)
|
if size<0 or size>settings.DRIVE_MAX_FILE_SIZE:return error('Taille de fichier non autorisée.',413)
|
||||||
|
try:
|
||||||
|
assert_available(space,parent,name)
|
||||||
|
except NameConflict as ex: return error(str(ex),409)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
with locked_quota_tree(space):
|
with locked_quota_tree(space):
|
||||||
if UploadSession.objects.filter(owner=request.user).count()>=20:
|
if UploadSession.objects.filter(owner=request.user).count()>=20:
|
||||||
@ -131,17 +134,31 @@ def upload_finish(request,upload_id):
|
|||||||
if not can_write_space(request.user,job.space) or (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user))):
|
if not can_write_space(request.user,job.space) or (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user))):
|
||||||
return error('Dossier de destination indisponible.',403)
|
return error('Dossier de destination indisponible.',403)
|
||||||
key=str(uuid.uuid4())
|
key=str(uuid.uuid4())
|
||||||
location=new_storage_location(job.space,key)
|
location=file_location(job.space,job.parent,job.name)
|
||||||
dst=Path(settings.DRIVE_STORAGE_ROOT)/location
|
created=False
|
||||||
dst.parent.mkdir(parents=True,exist_ok=True)
|
try:
|
||||||
os.replace(src,dst)
|
dst=assert_available(job.space,job.parent,job.name)
|
||||||
|
prepare_parent(job.space,job.parent)
|
||||||
|
# Exclusive creation: never overwrite a file already on disk.
|
||||||
|
# Copy, rather than rename: the temporary directory can be another volume.
|
||||||
|
with src.open('rb') as source, dst.open('xb') as output:
|
||||||
|
created=True
|
||||||
|
import shutil
|
||||||
|
shutil.copyfileobj(source,output,2*1024*1024)
|
||||||
|
except (NameConflict,FileExistsError) as exc:
|
||||||
|
if created:dst.unlink(missing_ok=True)
|
||||||
|
return error(str(exc),409)
|
||||||
|
except OSError as exc:
|
||||||
|
if created:dst.unlink(missing_ok=True)
|
||||||
|
return error(f'Finalisation impossible : {exc}')
|
||||||
mime=mimetypes.guess_type(job.name)[0] or 'application/octet-stream'
|
mime=mimetypes.guess_type(job.name)[0] or 'application/octet-stream'
|
||||||
try:
|
try:
|
||||||
node=DriveItem.objects.create(owner=request.user,space=job.space,parent=job.parent,name=job.name,
|
node=DriveItem.objects.create(owner=request.user,space=job.space,parent=job.parent,name=job.name,
|
||||||
size=job.total_size,mime_type=mime,storage_key=key,storage_location=location)
|
size=job.total_size,mime_type=mime,storage_key=key,storage_location=location)
|
||||||
job.delete()
|
job.delete()
|
||||||
|
transaction.on_commit(lambda: src.unlink(missing_ok=True))
|
||||||
except Exception:
|
except Exception:
|
||||||
os.replace(dst,src)
|
dst.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
return Response(item_info(node,request.user),status=201)
|
return Response(item_info(node,request.user),status=201)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user