working FileIndex and user Profile pages/settings
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0.3 on 2026-08-03 18:14
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FileIndex',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('kind', models.CharField(db_index=True, default='file', max_length=64)),
|
||||
('stored_path', models.CharField(max_length=1024)),
|
||||
('original_filename', models.CharField(blank=True, default='', max_length=255)),
|
||||
('content_type', models.CharField(blank=True, default='', max_length=128)),
|
||||
('size', models.IntegerField(default=0)),
|
||||
('md5', models.CharField(db_index=True, max_length=32)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['owner', 'kind'], name='library_fil_owner_i_c2b465_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,37 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class FileIndex(models.Model):
|
||||
"""Tracks every user file so media stays organized and served only
|
||||
through the gated FileRequest endpoint (never direct /media/ access)."""
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='files',
|
||||
)
|
||||
kind = models.CharField(max_length=64, default='file', db_index=True)
|
||||
stored_path = models.CharField(max_length=1024)
|
||||
original_filename = models.CharField(max_length=255, blank=True, default='')
|
||||
content_type = models.CharField(max_length=128, blank=True, default='')
|
||||
size = models.IntegerField(default=0)
|
||||
md5 = models.CharField(max_length=32, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['owner', 'kind']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.kind} {self.uuid} ({self.owner})'
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
return self.stored_path
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'library'
|
||||
|
||||
urlpatterns = [
|
||||
path('files/<uuid:file_id>/', views.file_request, name='file_request'),
|
||||
]
|
||||
+102
-2
@@ -1,3 +1,103 @@
|
||||
from django.shortcuts import render
|
||||
import posixpath
|
||||
from pathlib import Path
|
||||
|
||||
# Create your views here.
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from .models import FileIndex
|
||||
|
||||
|
||||
def _open_indexed_file(file_index):
|
||||
"""Return an open binary file handle for the indexed file (404-safe)."""
|
||||
stored = file_index.stored_path
|
||||
if not stored or posixpath.isabs(stored) or '..' in stored.split('/'):
|
||||
raise Http404
|
||||
file_path = (Path(settings.MEDIA_ROOT) / stored).resolve()
|
||||
if not file_path.is_relative_to(Path(settings.MEDIA_ROOT).resolve()):
|
||||
raise Http404
|
||||
if not file_path.is_file():
|
||||
raise Http404
|
||||
return open(file_path, 'rb')
|
||||
|
||||
|
||||
def _unsatisfiable(content_length):
|
||||
resp = HttpResponse(status=416)
|
||||
resp['Content-Range'] = f'bytes */{content_length}'
|
||||
return resp
|
||||
|
||||
|
||||
def file_request(request, file_id):
|
||||
"""Serve an indexed file behind the gate, honouring HTTP byte ranges.
|
||||
|
||||
GET /api/files/<uuid>/ → full file (200)
|
||||
Range: bytes=0-99 → partial content (206)
|
||||
Range: bytes=<start>- → tail from <start>
|
||||
Range: bytes=-<suffix> → last <suffix> bytes
|
||||
?download=1 → Content-Disposition: attachment
|
||||
"""
|
||||
file_index = get_object_or_404(FileIndex, uuid=file_id)
|
||||
fh = _open_indexed_file(file_index)
|
||||
|
||||
content_type = file_index.content_type or 'application/octet-stream'
|
||||
content_length = file_index.size or 0
|
||||
disposition = ''
|
||||
if request.GET.get('download') == '1':
|
||||
filename = file_index.original_filename or posixpath.basename(file_index.stored_path)
|
||||
disposition = f'attachment; filename="{filename}"'
|
||||
|
||||
range_header = request.META.get('HTTP_RANGE', '').strip()
|
||||
if not range_header or not range_header.lower().startswith('bytes='):
|
||||
response = FileResponse(fh, content_type=content_type)
|
||||
response['Accept-Ranges'] = 'bytes'
|
||||
response['Content-Length'] = str(content_length)
|
||||
if disposition:
|
||||
response['Content-Disposition'] = disposition
|
||||
return response
|
||||
|
||||
try:
|
||||
start, _, end = range_header[6:].partition('-')
|
||||
start = int(start) if start else None
|
||||
end = int(end) if end else None
|
||||
except ValueError:
|
||||
return _unsatisfiable(content_length)
|
||||
|
||||
if start is None and end is None:
|
||||
return _unsatisfiable(content_length)
|
||||
|
||||
if start is None:
|
||||
suffix = end
|
||||
if suffix <= 0:
|
||||
return _unsatisfiable(content_length)
|
||||
start = max(content_length - suffix, 0)
|
||||
end = content_length - 1
|
||||
elif end is None:
|
||||
end = content_length - 1
|
||||
|
||||
if start < 0 or start >= content_length or end < start:
|
||||
return _unsatisfiable(content_length)
|
||||
|
||||
end = min(end, content_length - 1)
|
||||
length = end - start + 1
|
||||
fh.seek(start)
|
||||
|
||||
def chunk_generator():
|
||||
try:
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
data = fh.read(min(8192, remaining))
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
part = StreamingHttpResponse(chunk_generator(), content_type=content_type)
|
||||
part['Content-Range'] = f'bytes {start}-{end}/{content_length}'
|
||||
part['Accept-Ranges'] = 'bytes'
|
||||
part['Content-Length'] = str(length)
|
||||
part.status_code = 206
|
||||
if disposition:
|
||||
part['Content-Disposition'] = disposition
|
||||
return part
|
||||
|
||||
Reference in New Issue
Block a user