diff --git a/README.md b/README.md index 33da4c1..6204b59 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - [X] Upload **single files**, **multiple files** or entire **folders** with **drag & drop** support - [X] **Download files** with original filenames preserved +- [X] **Preview files** (text, images, PDFs) directly in the browser - [X] **Recursive folder downloads** as `.zip` archives - [X] Unlimited **nested folder hierarchy** support - [X] File and folder **search** @@ -85,6 +86,8 @@ uv run manage.py test ![Folder Contents](/assets/folder_contents.png) +![Preview File](/assets/preview_file.png) + ![Search](/assets/search.png) ![Delete File](/assets/delete_file.png) diff --git a/assets/preview_file.png b/assets/preview_file.png new file mode 100644 index 0000000..de61e71 Binary files /dev/null and b/assets/preview_file.png differ diff --git a/files/tests/test_views.py b/files/tests/test_views.py index b78e909..d6e1bd9 100644 --- a/files/tests/test_views.py +++ b/files/tests/test_views.py @@ -478,3 +478,134 @@ def test_successful_deletion_of_root_folder(self): def tearDownClass(cls): shutil.rmtree(TEMP_MEDIA_ROOT, ignore_errors=True) super().tearDownClass() + + +@override_settings(MEDIA_ROOT=TEMP_MEDIA_ROOT, MAX_PREVIEW_SIZE=25 * 1024 * 1024) +class PreviewFileViewTests(TestCase): + + def setUp(self): + self.client = Client() + + self.user1 = User.objects.create_user(username='user1', password='password123') + self.user2 = User.objects.create_user(username='user2', password='password123') + + self.text_file = File.objects.create( + file=SimpleUploadedFile("readme.txt", b"test content"), name="readme.txt", size=11, owner=self.user1 + ) + self.image_file = File.objects.create( + file=SimpleUploadedFile("photo.png", b"test content"), name="photo.png", size=8, owner=self.user1 + ) + self.pdf_file = File.objects.create( + file=SimpleUploadedFile("doc.pdf", b"test content"), name="doc.pdf", size=8, owner=self.user1 + ) + + def test_unauthenticated_user_redirects_to_login(self): + url = reverse('files:file_preview', kwargs={'pk': self.text_file.pk}) + response = self.client.get(url) + self.assertRedirects(response, f'/user/login/?next={url}') + + def test_user_cannot_preview_other_users_file(self): + self.client.login(username='user2', password='password123') + url = reverse('files:file_preview', kwargs={'pk': self.text_file.pk}) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_preview_text_file(self): + self.client.login(username='user1', password='password123') + url = reverse('files:file_preview', kwargs={'pk': self.text_file.pk}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['file_type'], 'text') + self.assertEqual(response.context['content'], 'test content') + self.assertFalse(response.context['too_large']) + self.assertIsNone(response.context['error']) + + def test_preview_image_file(self): + self.client.login(username='user1', password='password123') + url = reverse('files:file_preview', kwargs={'pk': self.image_file.pk}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['file_type'], 'image') + self.assertTrue(response.context['content'].startswith('data:image/png;base64,')) + + def test_preview_pdf_file_type(self): + self.client.login(username='user1', password='password123') + url = reverse('files:file_preview', kwargs={'pk': self.pdf_file.pk}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['file_type'], 'pdf') + + @override_settings(MAX_PREVIEW_SIZE=5) + def test_too_large_file_returns_too_large_flag(self): + self.client.login(username='user1', password='password123') + url = reverse('files:file_preview', kwargs={'pk': self.text_file.pk}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context['too_large']) + self.assertIsNone(response.context['content']) + + def test_nonexistent_file_returns_404(self): + self.client.login(username='user1', password='password123') + url = reverse('files:file_preview', kwargs={'pk': 99999}) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(TEMP_MEDIA_ROOT, ignore_errors=True) + super().tearDownClass() + + +@override_settings(MEDIA_ROOT=TEMP_MEDIA_ROOT) +class PreviewPdfViewTests(TestCase): + + def setUp(self): + self.client = Client() + + self.user1 = User.objects.create_user(username='user1', password='password123') + self.user2 = User.objects.create_user(username='user2', password='password123') + + self.pdf_file = File.objects.create( + file=SimpleUploadedFile("doc.pdf", b"test content"), name="doc.pdf", size=8, owner=self.user1 + ) + + def test_unauthenticated_user_redirects_to_login(self): + url = reverse('files:preview_pdf', kwargs={'pk': self.pdf_file.pk}) + response = self.client.get(url) + self.assertRedirects(response, f'/user/login/?next={url}') + + def test_user_cannot_preview_other_users_pdf(self): + self.client.login(username='user2', password='password123') + url = reverse('files:preview_pdf', kwargs={'pk': self.pdf_file.pk}) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_successful_pdf_response_headers(self): + self.client.login(username='user1', password='password123') + url = reverse('files:preview_pdf', kwargs={'pk': self.pdf_file.pk}) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/pdf') + + def test_successful_pdf_response_content(self): + self.client.login(username='user1', password='password123') + url = reverse('files:preview_pdf', kwargs={'pk': self.pdf_file.pk}) + response = self.client.get(url) + + self.assertEqual(b''.join(response.streaming_content), b"test content") + + def test_nonexistent_pdf_returns_404(self): + self.client.login(username='user1', password='password123') + url = reverse('files:preview_pdf', kwargs={'pk': 99999}) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(TEMP_MEDIA_ROOT, ignore_errors=True) + super().tearDownClass() diff --git a/files/urls.py b/files/urls.py index fe2b4a4..2d39c19 100644 --- a/files/urls.py +++ b/files/urls.py @@ -9,6 +9,8 @@ path("upload/files/", views.upload_files, name="upload_files"), path("upload/folder/", views.upload_folder, name="upload_folder"), + path("files//view", views.preview_file, name="file_preview"), + path('file//view/pdf/', views.preview_pdf, name='preview_pdf'), path("files//download", views.download_file, name="file_download"), path("files//delete", views.delete_file, name="file_delete"), diff --git a/files/views.py b/files/views.py index 183350f..544b7c2 100644 --- a/files/views.py +++ b/files/views.py @@ -1,12 +1,16 @@ +import base64 import zipfile +import mimetypes from io import BytesIO from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required from django.contrib import messages from django.views.decorators.http import require_POST +from django.views.decorators.clickjacking import xframe_options_sameorigin from django.http import HttpResponse, FileResponse, Http404 from .models import Folder, File from .utils import add_folder_to_zip +from django.conf import settings @login_required def drive(request, folder_id=None): @@ -65,6 +69,53 @@ def upload_folder(request): return redirect("files:drive_folder", folder_id=root_folder.id) if root_folder else redirect("files:drive") +@login_required +@xframe_options_sameorigin +def preview_pdf(request, pk): + file = get_object_or_404(File, pk=pk, owner=request.user) + + response = FileResponse(file.file.open("rb"), content_type="application/pdf") + response["Content-Disposition"] = f'inline; filename="{file.name}"' + + return response + +@login_required +def preview_file(request, pk): + file = get_object_or_404(File, pk=pk, owner=request.user) + + context = { + "file": file, + "file_type": "text", + "content": None, + "too_large": False, + "error": None, + } + + mime_type, _ = mimetypes.guess_type(file.name) + if mime_type and mime_type.startswith("image/"): + context["file_type"] = "image" + elif mime_type == "application/pdf": + context["file_type"] = "pdf" + context["content"] = True + + if file.size > settings.MAX_PREVIEW_SIZE: + context["too_large"] = True + return render(request, "files/preview.html", context) + + try: + with file.file.open("rb") as file: + file.seek(0) + raw = file.read() + if context["file_type"] == "image": + encoded = base64.b64encode(raw).decode("utf-8") + context["content"] = f"data:{mime_type};base64,{encoded}" + else: + context["content"] = raw.decode("utf-8") + except Exception: + context["error"] = "Unable to read this file." + + return render(request, "files/preview.html", context) + @login_required def download_file(request, pk): file = get_object_or_404(File, pk=pk, owner=request.user) diff --git a/main/settings.py b/main/settings.py index d23bcb8..2af57d9 100644 --- a/main/settings.py +++ b/main/settings.py @@ -191,3 +191,5 @@ DISCORD_BOT_USERNAME = os.getenv("DISCORD_BOT_USERNAME") DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL") + +MAX_PREVIEW_SIZE = 25 * 1024 * 1024 diff --git a/templates/base.html b/templates/base.html index 1e7cf34..056d876 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,6 +11,8 @@ + {% block extra_head %}{% endblock extra_head %} + +{% endblock extra_head %} + +{% block content %} +
+
+ + + +

+ + {{ file.name }} + {{ file.size|file_size }} +

+
+ + Download + +
+ +
+ + {% if content %} + {% if file_type == 'text' %} +
+
{{ content }}
+
+ {% elif file_type == 'image' %} +
+ {{ file.name }} +
+ {% elif file_type == 'pdf' %} +
+ +
+ {% endif %} + {% elif too_large %} +
+ +

This file is too large to preview ({{ file.size|file_size }}).
Maximum preview size is 5 MB.

+ + Download instead + +
+ {% elif error %} +
+ +

{{ error }}

+ + Download instead + +
+ {% endif %} + + + + +{% endblock content %}