Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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)
Binary file added assets/preview_file.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
131 changes: 131 additions & 0 deletions files/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 2 additions & 0 deletions files/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
path("upload/files/", views.upload_files, name="upload_files"),
path("upload/folder/", views.upload_folder, name="upload_folder"),

path("files/<int:pk>/view", views.preview_file, name="file_preview"),
path('file/<int:pk>/view/pdf/', views.preview_pdf, name='preview_pdf'),
path("files/<int:pk>/download", views.download_file, name="file_download"),
path("files/<int:pk>/delete", views.delete_file, name="file_delete"),

Expand Down
51 changes: 51 additions & 0 deletions files/views.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<link rel="stylesheet" type="text/css" href="{% static 'main.css' %}">
<link rel="icon" type="image/png" href="{% static 'favicon.png' %}">

{% block extra_head %}{% endblock extra_head %}

<script>
const getStoredTheme = () => localStorage.getItem('theme');
const setStoredTheme = theme => localStorage.setItem('theme', theme);
Expand Down
3 changes: 3 additions & 0 deletions templates/files/drive.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ <h6 class="mb-0 text-truncate">{{ file.name }}</h6>
</div>

<div class="d-flex align-items-center gap-2 p-3 pt-0 pt-md-3 justify-content-end">
<a href="{% url 'files:file_preview' file.id %}" class="btn btn-info btn-sm flex-fill flex-md-grow-0">
<i class="bi bi-eye"></i> <span class="d-none d-sm-inline">View</span>
</a>
<a href="{% url 'files:file_download' file.id %}" class="btn btn-success btn-sm flex-fill flex-md-grow-0">
<i class="bi bi-download"></i> <span class="d-none d-sm-inline">Download</span>
</a>
Expand Down
78 changes: 78 additions & 0 deletions templates/files/preview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% load file_size %}

{% block title %}{{ file.name }}{% endblock title %}

{% block extra_head %}
<link id="hljs-theme" rel="stylesheet" href="">
<script>
(function() {
var theme = localStorage.getItem('theme');
if (!theme || theme === 'auto') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
var href = theme === 'dark'
? 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/github-dark.min.css'
: 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/github.min.css';
document.getElementById('hljs-theme').href = href;
})();
</script>
{% endblock extra_head %}

{% block content %}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-2 mb-3">
<div class="d-flex align-items-center gap-2">
<a href="{% if file.folder %}{% url 'files:drive_folder' file.folder.id %}{% else %}{% url 'files:drive' %}{% endif %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i>
</a>
<h4 class="mb-0 d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-text text-primary"></i>
<span class="text-truncate" style="max-width: 400px;">{{ file.name }}</span>
<span class="badge bg-secondary small px-2 py-1">{{ file.size|file_size }}</span>
</h4>
</div>
<a href="{% url 'files:file_download' file.id %}" class="btn btn-success btn-sm">
<i class="bi bi-download"></i> Download
</a>
</div>

<hr>

{% if content %}
{% if file_type == 'text' %}
<div class="border rounded overflow-auto" style="max-height: 80vh; font-size: 0.875rem;">
<pre class="m-0 p-3"><code>{{ content }}</code></pre>
</div>
{% elif file_type == 'image' %}
<div class="border rounded overflow-auto d-flex justify-content-center align-items-center">
<img src="{{ content }}" alt="{{ file.name }}" class="img-fluid">
</div>
{% elif file_type == 'pdf' %}
<div class="border rounded overflow-hidden" style="height: 80vh;">
<object data="{% url 'files:preview_pdf' file.id %}" type="application/pdf" width="100%" height="100%"></object>
</div>
{% endif %}
{% elif too_large %}
<div class="d-flex flex-column justify-content-center align-items-center text-center text-muted py-5">
<i class="bi bi-exclamation-triangle display-1"></i>
<p class="mt-3">This file is too large to preview ({{ file.size|file_size }}).<br>Maximum preview size is 5 MB.</p>
<a href="{% url 'files:file_download' file.id %}" class="btn btn-success">
<i class="bi bi-download"></i> Download instead
</a>
</div>
{% elif error %}
<div class="d-flex flex-column justify-content-center align-items-center text-center text-muted py-5">
<i class="bi bi-file-earmark-x display-1"></i>
<p class="mt-3">{{ error }}</p>
<a href="{% url 'files:file_download' file.id %}" class="btn btn-success">
<i class="bi bi-download"></i> Download instead
</a>
</div>
{% endif %}

<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>

<script>
hljs.highlightAll();
</script>
{% endblock content %}
Loading