This commit is contained in:
2026-07-28 21:15:48 +10:00
parent 30ef51eb03
commit e61da159ac
4 changed files with 101 additions and 12 deletions
+63 -8
View File
@@ -23,27 +23,63 @@ from .serializers import FileUploadSerializer
logger = logging.getLogger(__name__)
class FileSaveError(Exception):
"""Raised when an uploaded file could not be fully persisted to disk."""
def _get_upload_folder():
folder = settings.FILE_UPLOADS_FOLDER
Path(folder).mkdir(parents=True, exist_ok=True)
return folder
def _cleanup_partial(path):
try:
if os.path.exists(path):
os.remove(path)
except OSError:
logger.warning("Failed to clean up partial upload file %s", path, exc_info=True)
def _save_uploaded_file(f):
"""Save an in-memory upload to FILE_UPLOADS_FOLDER and return a FileUpload instance."""
"""Save an in-memory upload to FILE_UPLOADS_FOLDER and return a FileUpload instance.
Writes to a temporary path first, fsyncs, and verifies the byte count on
disk matches what was uploaded *before* creating the DB record or moving
the file into its final place. This prevents "phantom" records that point
at a missing/truncated file if the underlying storage (e.g. a flaky NFS
mount) acknowledges a write that never actually lands on disk.
"""
folder = _get_upload_folder()
ext = Path(f.name).suffix.lower()
stored_name = f"{secrets.token_hex(16)}{ext}"
dest_path = os.path.join(folder, stored_name)
with open(dest_path, 'wb') as dst:
for chunk in f.chunks():
dst.write(chunk)
tmp_path = f"{dest_path}.part"
try:
with open(tmp_path, 'wb') as dst:
for chunk in f.chunks():
dst.write(chunk)
dst.flush()
os.fsync(dst.fileno())
except OSError as exc:
_cleanup_partial(tmp_path)
raise FileSaveError(f"Failed to write \"{f.name}\" to storage: {exc}") from exc
actual_size = os.path.getsize(tmp_path)
if actual_size != f.size:
_cleanup_partial(tmp_path)
raise FileSaveError(
f"Upload of \"{f.name}\" is incomplete ({actual_size}/{f.size} bytes written)."
)
os.rename(tmp_path, dest_path)
mime_type = f.content_type or mimetypes.guess_type(f.name)[0] or 'application/octet-stream'
return FileUpload.objects.create(
name=f.name,
stored_name=stored_name,
mime_type=mime_type,
size=f.size,
size=actual_size,
)
@@ -132,8 +168,14 @@ class FileUploadView(View):
return JsonResponse({'error': 'No files provided'}, status=400)
return redirect('file-list')
results = []
errors = []
for f in uploaded:
record = _save_uploaded_file(f)
try:
record = _save_uploaded_file(f)
except FileSaveError as exc:
logger.error("Upload failed for %s: %s", f.name, exc)
errors.append({'name': f.name, 'error': str(exc)})
continue
results.append({
'id': str(record.pk),
'name': record.name,
@@ -149,7 +191,8 @@ class FileUploadView(View):
'is_image': record.is_image,
})
if is_ajax:
return JsonResponse({'uploaded': results})
status_code = 200 if results else 500
return JsonResponse({'uploaded': results, 'errors': errors}, status=status_code)
return redirect('file-list')
@@ -264,7 +307,19 @@ class FileUploadViewSet(viewsets.ModelViewSet):
uploaded = request.FILES.getlist('files')
if not uploaded:
return Response({'error': 'No files provided. Use "files" field.'}, status=status.HTTP_400_BAD_REQUEST)
created = [_save_uploaded_file(f) for f in uploaded]
created = []
errors = []
for f in uploaded:
try:
created.append(_save_uploaded_file(f))
except FileSaveError as exc:
logger.error("Upload failed for %s: %s", f.name, exc)
errors.append({'name': f.name, 'error': str(exc)})
if not created:
return Response(
{'error': 'Failed to save uploaded file(s).', 'errors': errors},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
serializer = self.get_serializer(created, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
+10 -4
View File
@@ -302,14 +302,15 @@
<div class="px-4 py-3">
<div class="flex items-center justify-between mb-1.5">
<span class="text-xs font-medium text-gray-700 truncate max-w-[180px]" x-text="u.name"></span>
<span class="text-xs ml-2 flex-shrink-0 font-medium"
<span class="text-xs ml-2 flex-shrink-0 font-medium truncate max-w-[140px]"
:class="{
'text-blue-500': u.status === 'uploading',
'text-green-600': u.status === 'done',
'text-red-500': u.status === 'error',
'text-gray-400': u.status === 'cancelled'
}"
x-text="u.status === 'uploading' ? u.progress + '%' : u.status"></span>
:title="u.errorMsg || ''"
x-text="u.status === 'uploading' ? u.progress + '%' : (u.errorMsg || u.status)"></span>
</div>
<div class="w-full bg-gray-100 rounded-full h-1.5">
<div class="h-1.5 rounded-full transition-all duration-200"
@@ -494,7 +495,7 @@ function fileManager() {
this.hudOpen = true;
Array.from(fileList).forEach(file => {
const id = Date.now() + '_' + Math.random().toString(36).slice(2);
this.uploads.push({ id, name: file.name, progress: 0, status: 'uploading', xhr: null });
this.uploads.push({ id, name: file.name, progress: 0, status: 'uploading', errorMsg: '', xhr: null });
this._uploadOne(file, id);
});
},
@@ -544,6 +545,11 @@ function fileManager() {
} catch (e) { /* ignore parse error */ }
} else {
u.status = 'error';
try {
const data = JSON.parse(xhr.responseText);
const firstError = data && Array.isArray(data.errors) && data.errors[0];
if (firstError && firstError.error) u.errorMsg = firstError.error;
} catch (e) { /* ignore parse error, keep generic 'error' */ }
}
}
this._maybeCollapseHud();
@@ -731,4 +737,4 @@ function formatBytes(bytes) {
}
</script>
{% endblock %}
{% endblock %}
+6
View File
@@ -417,6 +417,12 @@ paths:
$ref: '#/components/schemas/FileUpload'
'400':
description: No files provided
'500':
description: >-
None of the uploaded files could be fully persisted to storage
(e.g. a storage write failure or truncated upload). The response
body includes an `errors` array with a `name`/`error` entry per
failed file.
/api/files/{id}:
get:
+22
View File
@@ -73,6 +73,28 @@ class TestFilesAPI:
r = api_client.post(f"{self.BASE}", {}, format="multipart")
assert r.status_code == 400
def test_upload_write_failure_returns_500_and_no_phantom_record(self, api_client, monkeypatch):
"""If the file can't be fully persisted to disk (e.g. storage failure),
no FileUpload DB record should be created — otherwise it points at a
file that will always 404."""
import os as _os
from links import file_views
def _boom(*args, **kwargs):
raise OSError("simulated storage failure")
monkeypatch.setattr(file_views.os, "fsync", _boom)
from links.models import FileUpload
r = api_client.post(f"{self.BASE}", {"files": _png()}, format="multipart")
assert r.status_code == 500
assert "errors" in r.json()
assert FileUpload.objects.count() == 0
# No leftover partial file should remain in the uploads folder.
assert list(_os.scandir(file_views._get_upload_folder())) == []
def test_delete_file(self, api_client):
pk = api_client.post(
f"{self.BASE}", {"files": _png()}, format="multipart"