rfiles-online/files/models.py

404 lines
12 KiB
Python

"""
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/<uuid>/<original_filename>
"""
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}"