149 lines
6.4 KiB
Python
149 lines
6.4 KiB
Python
"""Read-only sharing of individual items and folder descendants.
|
||
|
||
User shares grant no space-level privileges; public links use cryptographically
|
||
random bearer tokens and keep only SHA-256 digests in the database.
|
||
"""
|
||
import hashlib
|
||
import secrets
|
||
from django.contrib.auth import get_user_model
|
||
from django.core.exceptions import ValidationError
|
||
from django.http import FileResponse, Http404
|
||
from django.db.models import Q
|
||
from django.utils import timezone
|
||
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
||
from rest_framework.permissions import AllowAny
|
||
from rest_framework.throttling import AnonRateThrottle
|
||
from rest_framework.response import Response
|
||
from .models import DriveItem, ItemGrant, PublicItemLink
|
||
from .services import error, get_item, readable, writable, chain, item_info
|
||
from .spaces import storage_path
|
||
|
||
|
||
def shareable(item, user):
|
||
return readable(item, user) and writable(item, user)
|
||
|
||
|
||
def share_info(grant):
|
||
u=grant.user
|
||
return {'id': grant.pk,'user_id':u.pk,'username':u.username,
|
||
'first_name':u.first_name,'last_name':u.last_name}
|
||
|
||
|
||
@api_view(['GET'])
|
||
def search_share_users(request):
|
||
q=str(request.query_params.get('q','')).strip()[:100]
|
||
if len(q)<2:return Response({'users':[]})
|
||
User=get_user_model()
|
||
users=User.objects.filter(is_active=True).exclude(pk=request.user.pk).filter(
|
||
Q(username__icontains=q) |
|
||
Q(first_name__icontains=q) |
|
||
Q(last_name__icontains=q)
|
||
).order_by('username')[:15]
|
||
return Response({'users':[{'id':u.pk,'username':u.username,
|
||
'first_name':u.first_name,'last_name':u.last_name} for u in users]})
|
||
|
||
|
||
@api_view(['GET','POST'])
|
||
def manage_item_shares(request,item_id):
|
||
item=get_item(item_id)
|
||
if not shareable(item,request.user):return error('Partage non autorisé.',403)
|
||
if request.method=='GET':
|
||
grants=ItemGrant.objects.filter(item=item).select_related('user').order_by('user__username')
|
||
links=PublicItemLink.objects.filter(item=item,revoked_at__isnull=True).order_by('-created_at')
|
||
return Response({'users':[share_info(g) for g in grants],
|
||
'links':[{'id':l.pk,'created_at':l.created_at.isoformat()} for l in links]})
|
||
if item.trashed_at:return error('Impossible de partager un élément supprimé.',400)
|
||
try:user_id=int(request.data.get('user_id'))
|
||
except (TypeError,ValueError):return error('Utilisateur invalide.')
|
||
try:account=get_user_model().objects.get(pk=user_id,is_active=True)
|
||
except get_user_model().DoesNotExist:return error('Utilisateur introuvable.',404)
|
||
if account.pk==request.user.pk:return error('Cet élément t’appartient déjà.')
|
||
grant,created=ItemGrant.objects.get_or_create(item=item,user=account,defaults={'created_by':request.user})
|
||
if not created:return error('Cet élément est déjà partagé avec cet utilisateur.',409)
|
||
return Response(share_info(grant),status=201)
|
||
|
||
|
||
@api_view(['DELETE'])
|
||
def remove_item_share(request,item_id,grant_id):
|
||
item=get_item(item_id)
|
||
if not shareable(item,request.user):return error('Partage non autorisé.',403)
|
||
deleted,_=ItemGrant.objects.filter(pk=grant_id,item=item).delete()
|
||
if not deleted:return error('Partage introuvable.',404)
|
||
return Response(status=204)
|
||
|
||
|
||
@api_view(['POST'])
|
||
def public_links(request,item_id):
|
||
item=get_item(item_id)
|
||
if not shareable(item,request.user) or not readable(item,request.user):
|
||
return error('Partage non autorisé.',403)
|
||
token=secrets.token_urlsafe(32)
|
||
PublicItemLink.objects.create(item=item,token_hash=hashlib.sha256(token.encode()).hexdigest(),created_by=request.user)
|
||
return Response({'token':token},status=201)
|
||
|
||
|
||
@api_view(['DELETE'])
|
||
def revoke_public_link(request,item_id,link_id):
|
||
item=get_item(item_id)
|
||
if not shareable(item,request.user):return error('Partage non autorisé.',403)
|
||
changed=PublicItemLink.objects.filter(pk=link_id,item=item,revoked_at__isnull=True).update(revoked_at=timezone.now())
|
||
if not changed:return error('Lien introuvable.',404)
|
||
return Response(status=204)
|
||
|
||
|
||
def get_public_root(token):
|
||
if not token or len(token)>128:raise Http404
|
||
digest=hashlib.sha256(token.encode()).hexdigest()
|
||
try:link=PublicItemLink.objects.select_related('item','item__parent','item__space').get(
|
||
token_hash=digest,revoked_at__isnull=True)
|
||
except PublicItemLink.DoesNotExist:raise Http404
|
||
if any(n.trashed_at for n in chain(link.item)):raise Http404
|
||
return link.item
|
||
|
||
|
||
def in_public_tree(root, candidate):
|
||
if candidate.space_id!=root.space_id:return False
|
||
return any(ancestor.pk==root.pk for ancestor in chain(candidate)) and not any(n.trashed_at for n in chain(candidate))
|
||
|
||
|
||
@api_view(['GET'])
|
||
@permission_classes([AllowAny])
|
||
@throttle_classes([AnonRateThrottle])
|
||
def public_share(request,token):
|
||
root=get_public_root(token)
|
||
parent_id=request.query_params.get('parent')
|
||
if parent_id:
|
||
try:folder=get_item(parent_id)
|
||
except (Http404,ValueError,ValidationError):raise Http404
|
||
else:folder=root
|
||
if not folder.is_folder or not in_public_tree(root,folder):
|
||
if not parent_id and not root.is_folder:
|
||
return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':False},'items':[]})
|
||
raise Http404
|
||
qs=DriveItem.objects.filter(parent=folder,trashed_at__isnull=True).order_by('-is_folder','name')[:501]
|
||
items=[{'id':str(i.pk),'name':i.name,'is_folder':i.is_folder,'size':i.size} for i in qs]
|
||
# Never list ancestors outside the shared root.
|
||
breadcrumb=[]
|
||
for node in reversed(chain(folder)):
|
||
if node.pk==root.pk or breadcrumb:
|
||
breadcrumb.append({'id':str(node.pk),'name':node.name})
|
||
return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':True},
|
||
'breadcrumb':breadcrumb,'items':items[:500],'limited':len(items)>500})
|
||
|
||
|
||
@api_view(['GET','HEAD'])
|
||
@permission_classes([AllowAny])
|
||
@throttle_classes([AnonRateThrottle])
|
||
def public_file(request,token,item_id):
|
||
root=get_public_root(token)
|
||
item=get_item(item_id)
|
||
if item.is_folder or not in_public_tree(root,item):raise Http404
|
||
path=storage_path(item)
|
||
if not path.is_file():raise Http404
|
||
response=FileResponse(path.open('rb'),as_attachment=True,filename=item.name,
|
||
content_type='application/octet-stream')
|
||
response['X-Content-Type-Options']='nosniff'
|
||
response['Cache-Control']='no-store'
|
||
response['Referrer-Policy']='no-referrer'
|
||
return response
|