working FileIndex and user Profile pages/settings

This commit is contained in:
JakeBreath
2026-08-03 14:13:40 -05:00
parent 85e6241ad4
commit 39e31d3980
17 changed files with 999 additions and 25 deletions
@@ -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')],
},
),
]
+35 -1
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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