""" File storage and public sharing models for rfiles.online. Provides file upload capability with optional public sharing via unique tokens with configurable expiration and access controls. Also supports password-protected shared spaces for collaborative uploads. """ import hashlib import secrets import uuid from pathlib import Path from django.conf import settings from django.contrib.auth.hashers import check_password, make_password from django.db import models from django.utils import timezone from simple_history.models import HistoricalRecords class SharedSpace(models.Model): """ Password-protected shared upload space accessible via subdomain. Users with the space password can view all files and upload new ones. Example: cofi.rfiles.online would be a SharedSpace with slug='cofi'. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) slug = models.SlugField( max_length=63, unique=True, help_text="Subdomain identifier (e.g., 'cofi' for cofi.rfiles.online)" ) name = models.CharField(max_length=255, help_text="Display name for the space") description = models.TextField(blank=True, default='') password_hash = models.CharField(max_length=128) is_active = models.BooleanField(default=True) max_file_size_mb = models.PositiveIntegerField( 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) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_spaces' ) class Meta: ordering = ['name'] verbose_name = 'Shared Space' verbose_name_plural = 'Shared Spaces' def __str__(self): return f"{self.name} ({self.slug}.rfiles.online)" def set_password(self, password): self.password_hash = make_password(password) def check_password(self, password): return check_password(password, self.password_hash) def get_url(self): return f"https://{self.slug}.rfiles.online" @property def file_count(self): return self.files.count() @property def total_size_bytes(self): return self.files.aggregate(total=models.Sum('file_size'))['total'] or 0 def media_upload_path(instance, filename): """ Generate upload path for media files. Format: uploads/YYYY/MM/DD// """ now = timezone.now() return f"uploads/{now.year}/{now.month:02d}/{now.day:02d}/{instance.id}/{filename}" class MediaFile(models.Model): """ Uploaded media file with metadata. Supports any file type - PDFs, images, documents, audio, video, etc. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # File data file = models.FileField(upload_to=media_upload_path) original_filename = models.CharField(max_length=255) mime_type = models.CharField(max_length=127, blank=True, default='') file_size = models.BigIntegerField(default=0, help_text="File size in bytes") file_hash = models.CharField( max_length=64, db_index=True, blank=True, default='', help_text="SHA256 hash for deduplication" ) # Metadata title = models.CharField(max_length=255, blank=True, default='') description = models.TextField(blank=True, default='') tags = models.JSONField(default=list, blank=True) # Optional extracted content (for OCR'd PDFs, etc.) extracted_text = models.TextField(blank=True, default='') # Ownership and timestamps uploaded_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='uploaded_files' ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # Link to shared space (for collaborative uploads) shared_space = models.ForeignKey( 'SharedSpace', null=True, blank=True, on_delete=models.SET_NULL, related_name='files', help_text="Shared space this file belongs to" ) # Processing status is_processed = models.BooleanField( default=False, help_text="Whether OCR/transcription has been run" ) processing_error = models.TextField(blank=True, default='') history = HistoricalRecords() class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['file_hash']), models.Index(fields=['mime_type']), models.Index(fields=['created_at']), ] def __str__(self): return self.title or self.original_filename def save(self, *args, **kwargs): if not self.title and self.original_filename: self.title = Path(self.original_filename).stem if self.file and not self.file_hash: self.file_hash = self.compute_file_hash() if self.file and not self.file_size: try: self.file_size = self.file.size except Exception: pass super().save(*args, **kwargs) def compute_file_hash(self): sha256 = hashlib.sha256() self.file.seek(0) for chunk in iter(lambda: self.file.read(8192), b''): sha256.update(chunk) self.file.seek(0) return sha256.hexdigest() @property def extension(self): return Path(self.original_filename).suffix.lower() @property def is_image(self): return self.mime_type.startswith('image/') @property def is_pdf(self): return self.mime_type == 'application/pdf' @property def is_audio(self): return self.mime_type.startswith('audio/') @property def is_video(self): return self.mime_type.startswith('video/') def get_public_shares(self): return self.public_shares.filter( models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now()) ) def generate_share_token(): return secrets.token_urlsafe(24) class PublicShare(models.Model): """ Public sharing link for a media file. Allows creating time-limited or permanent public URLs for files with optional password protection and download limits. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) token = models.CharField( max_length=64, unique=True, default=generate_share_token, db_index=True ) media_file = models.ForeignKey( MediaFile, on_delete=models.CASCADE, related_name='public_shares' ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_shares' ) created_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField( null=True, blank=True, help_text="Leave empty for permanent link" ) max_downloads = models.PositiveIntegerField( null=True, blank=True, help_text="Leave empty for unlimited downloads" ) download_count = models.PositiveIntegerField(default=0) is_password_protected = models.BooleanField(default=False) password_hash = models.CharField(max_length=128, blank=True, default='') note = models.CharField( max_length=255, blank=True, default='', help_text="Optional note about this share" ) is_active = models.BooleanField(default=True) class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['token']), models.Index(fields=['expires_at']), models.Index(fields=['is_active']), ] def __str__(self): return f"Share: {self.media_file.title or self.media_file.original_filename}" @property def is_expired(self): if self.expires_at is None: return False return timezone.now() > self.expires_at @property def is_download_limit_reached(self): if self.max_downloads is None: return False return self.download_count >= self.max_downloads @property def is_valid(self): return ( self.is_active and not self.is_expired and not self.is_download_limit_reached ) def set_password(self, password): from django.contrib.auth.hashers import make_password self.is_password_protected = True self.password_hash = make_password(password) self.save(update_fields=['is_password_protected', 'password_hash']) def check_password(self, password): from django.contrib.auth.hashers import check_password if not self.is_password_protected: return True return check_password(password, self.password_hash) def record_download(self, request=None): self.download_count += 1 self.save(update_fields=['download_count']) if request: FileAccessLog.objects.create( media_file=self.media_file, share=self, ip_address=self._get_client_ip(request), user_agent=request.META.get('HTTP_USER_AGENT', '')[:500], access_type='download' ) def _get_client_ip(self, request): x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: return x_forwarded_for.split(',')[0].strip() return request.META.get('REMOTE_ADDR', '') def get_public_url(self): from django.conf import settings base_url = getattr(settings, 'SHARE_BASE_URL', 'https://rfiles.online') return f"{base_url}/s/{self.token}" class FileAccessLog(models.Model): """ Audit log for file access events. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) media_file = models.ForeignKey( MediaFile, on_delete=models.CASCADE, related_name='access_logs' ) share = models.ForeignKey( PublicShare, on_delete=models.SET_NULL, null=True, blank=True, related_name='access_logs' ) accessed_at = models.DateTimeField(auto_now_add=True) ip_address = models.GenericIPAddressField(null=True, blank=True) user_agent = models.CharField(max_length=500, blank=True, default='') ACCESS_TYPES = [ ('download', 'Download'), ('view', 'View'), ('share_created', 'Share Created'), ('share_revoked', 'Share Revoked'), ] access_type = models.CharField(max_length=20, choices=ACCESS_TYPES) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True ) class Meta: ordering = ['-accessed_at'] indexes = [ models.Index(fields=['accessed_at']), models.Index(fields=['access_type']), ] def __str__(self): return f"{self.access_type} - {self.media_file} at {self.accessed_at}"