feat: enforce space visibility on uploads and API views

Add visibility and owner_did fields to SharedSpace model. Protect upload
endpoints with check_space_access(). Add SpacePermission DRF permission
class to MediaFileViewSet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 11:54:15 -07:00
parent e8ad0f7f31
commit 284a73d3fa
3 changed files with 53 additions and 3 deletions

View File

@ -40,6 +40,26 @@ class SharedSpace(models.Model):
default=0,
help_text="Maximum file size in MB for uploads (0 = unlimited)"
)
VISIBILITY_CHOICES = [
('public', 'Public'),
('public_read', 'Public Read'),
('authenticated', 'Authenticated'),
('members_only', 'Members Only'),
]
visibility = models.CharField(
max_length=20,
choices=VISIBILITY_CHOICES,
default='public_read',
help_text="Who can access this space"
)
owner_did = models.CharField(
max_length=255,
blank=True,
default='',
help_text="EncryptID DID of the space owner"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@ -13,7 +13,8 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from .models import MediaFile, PublicShare, FileAccessLog
from .models import MediaFile, PublicShare, FileAccessLog, SharedSpace
from config.encryptid_auth import SpacePermission
from .serializers import (
MediaFileSerializer,
MediaFileUploadSerializer,
@ -30,7 +31,18 @@ class MediaFileViewSet(viewsets.ModelViewSet):
queryset = MediaFile.objects.all()
serializer_class = MediaFileSerializer
parser_classes = [MultiPartParser, FormParser, JSONParser]
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
permission_classes = [permissions.IsAuthenticatedOrReadOnly, SpacePermission]
def get_space_config(self, request):
"""Provide space config for SpacePermission check."""
space_slug = request.query_params.get('space')
if not space_slug:
return None
try:
space = SharedSpace.objects.get(slug=space_slug, is_active=True)
return {'visibility': space.visibility, 'owner_did': space.owner_did}
except SharedSpace.DoesNotExist:
return None
def get_serializer_class(self):
if self.action == 'create':

View File

@ -12,6 +12,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from files.models import SharedSpace, MediaFile, PublicShare
from config.encryptid_auth import check_space_access
def get_topic_or_404(request):
@ -54,11 +55,19 @@ class SharedSpaceHomeView(View):
@method_decorator(csrf_exempt, name='dispatch')
class SharedSpaceUploadAPIView(View):
"""Handle file uploads via AJAX for topic."""
"""Handle file uploads via AJAX for topic. Requires auth for non-public spaces."""
def post(self, request):
space = get_topic_or_404(request)
# Check space visibility — uploads are always writes
access = check_space_access(request, {
'visibility': space.visibility,
'owner_did': space.owner_did,
})
if not access['allowed']:
return JsonResponse({'error': access['reason']}, status=401)
if not request.FILES.get('file'):
return JsonResponse({'error': 'No file provided'}, status=400)
@ -137,6 +146,7 @@ class DirectUploadAPIView(View):
"""Handle uploads via direct.rfiles.online (bypasses Cloudflare).
Space slug is passed as a form field instead of from the subdomain.
Requires auth for non-public spaces.
"""
def post(self, request):
@ -146,6 +156,14 @@ class DirectUploadAPIView(View):
space = get_object_or_404(SharedSpace, slug=space_slug, is_active=True)
# Check space visibility — uploads are always writes
access = check_space_access(request, {
'visibility': space.visibility,
'owner_did': space.owner_did,
})
if not access['allowed']:
return JsonResponse({'error': access['reason']}, status=401)
if not request.FILES.get('file'):
return JsonResponse({'error': 'No file provided'}, status=400)