feat: Introduce file upload functionality

This commit overhauls the user area with the addition of comprehensive
file upload capabilities for images and videos. Notably, it integrates
front-end enhancements for drag-and-drop uploads in the user area and
introduces a secure back-end handling process. The back-end adjustments
include a new media upload view and adjustments to image and video
models to support large file handling through settings for maximum image
pixel limits. Additionally, the refactor standardizes CSRF token
retrieval across JavaScript modules, improving security and code
maintainability.

- Front-end additions include detailed user feedback during the file
upload process, such as progress bars and success/error indicators.
- Back-end improvements ensure large image files are processed
efficiently, mitigating potential memory issues by configuring a maximum
image pixel threshold.
- Consolidating the CSRF token retrieval into the `api.js` module
centralizes security mechanisms, reducing redundancy and enhancing the
codebase's clarity.

Overall, these changes enrich the platform's media management
capabilities, bolster security practices, and improve user experience
through intuitive interface updates and robust back-end processing.
This commit is contained in:
Kumi 2024-03-16 21:30:12 +01:00
parent 06fb7ac04e
commit 7d78c5d4a0
Signed by: kumi
GPG key ID: ECBCC9082395383F
11 changed files with 308 additions and 26 deletions

View file

@ -1,3 +1,5 @@
/* Base "admin" layout */
body { body {
background-color: #2d2d30; background-color: #2d2d30;
color: #ccc; color: #ccc;
@ -22,7 +24,6 @@ body {
background-color: #1e1e1e; background-color: #1e1e1e;
color: #9cdcfe; color: #9cdcfe;
padding: 20px; padding: 20px;
height: calc(100vh - 50px);
overflow-y: auto; overflow-y: auto;
} }
@ -62,6 +63,8 @@ body {
border-color: #3c3c3c; border-color: #3c3c3c;
} }
/* Tabs */
.nav-tabs { .nav-tabs {
border-bottom: 1px solid #444; border-bottom: 1px solid #444;
} }
@ -88,6 +91,8 @@ body {
border-radius: 0.25rem; border-radius: 0.25rem;
} }
/* Tables */
.dataTables_wrapper { .dataTables_wrapper {
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
color: #9CDCFE; color: #9CDCFE;
@ -163,3 +168,62 @@ table.dataTable thead .sorting_desc_disabled:after {
table.dataTable { table.dataTable {
width: 100% !important; width: 100% !important;
} }
/* Uploads */
.drop-zone {
max-width: 480px;
height: 150px;
padding: 25px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
border: 2px dashed #009578;
border-radius: 10px;
font-family: sans-serif;
margin: 0 auto;
cursor: pointer;
}
.drop-zone--over {
background-color: #f0f0f0;
}
.drop-zone__input {
display: none;
}
.drop-zone__prompt {
color: #cccccc;
font-size: 20px;
}
.file-upload-wrapper {
margin-top: 20px;
}
.file-details {
display: flex;
align-items: center;
justify-content: space-between;
}
.file-name {
flex-grow: 1;
padding-right: 10px;
}
.thumbnail {
width: 128px;
height: 64px;
object-fit: cover;
}
.progress {
width: 100%;
}
.error-message {
color: red;
}

View file

@ -38,7 +38,6 @@ function getSceneElement(scene_uuid, uuid) {
} }
function getCategory(category) { function getCategory(category) {
return api return api
.then( .then(
(client) => (client) =>
@ -51,4 +50,20 @@ function getCategory(category) {
); );
} }
export { getScene, getSceneElement, getCategory }; // Function to get the CSRF token cookie. Not exactly "API", but fits here best.
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
export { getScene, getSceneElement, getCategory, getCookie };

View file

@ -1,26 +1,10 @@
import { getScene, getSceneElement, getCategory } from "./api"; import { getScene, getSceneElement, getCategory, getCookie } from "./api";
import { populateDestinationDropdown } from "./editor/teleport"; import { populateDestinationDropdown } from "./editor/teleport";
import "../css/editor.css"; import "../css/editor.css";
let clickTimestamp = 0; let clickTimestamp = 0;
// Function to get the CSRF token cookie
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// Find parent quackscape-scene for ID // Find parent quackscape-scene for ID
function findParentScene(element) { function findParentScene(element) {
var parent = element.parentElement; var parent = element.parentElement;

View file

@ -1,9 +1,130 @@
import '../scss/frontend.scss'; import '../scss/frontend.scss';
import '../css/userarea.css'; import '../css/userarea.css';
import { getCookie } from './api';
import { Tab } from 'bootstrap'; import { Tab } from 'bootstrap';
import DataTable from 'datatables.net-dt'; import DataTable from 'datatables.net-dt';
let mediaTable = new DataTable('#mediaTable'); let mediaTable = new DataTable('#mediaTable');
let scenesTable = new DataTable('#scenesTable'); let scenesTable = new DataTable('#scenesTable');
let permissionsTable = new DataTable('#permissionsTable'); let permissionsTable = new DataTable('#permissionsTable');
/* Uploads */
document.querySelectorAll('.drop-zone__input').forEach(inputElement => {
const dropZoneElement = inputElement.closest('.drop-zone');
dropZoneElement.addEventListener('click', e => {
inputElement.click();
});
dropZoneElement.addEventListener('dragover', e => {
e.preventDefault();
dropZoneElement.classList.add('drop-zone--over');
});
['dragleave', 'dragend', 'drop'].forEach(type => {
dropZoneElement.addEventListener(type, e => {
dropZoneElement.classList.remove('drop-zone--over');
});
});
dropZoneElement.addEventListener('drop', e => {
e.preventDefault();
if (e.dataTransfer.files.length) {
inputElement.files = e.dataTransfer.files;
handleFiles(inputElement.files);
}
});
inputElement.addEventListener('change', e => {
if (inputElement.files.length) {
handleFiles(inputElement.files);
}
});
});
function handleFiles(files) {
const uploadStatus = document.getElementById('uploadStatus');
Array.from(files).forEach(file => {
const fileRow = document.createElement('div');
fileRow.classList.add('row', 'align-items-center', 'mb-2', 'file-upload-wrapper');
const thumbnailCol = document.createElement('div');
thumbnailCol.classList.add('col-2');
const thumbnail = document.createElement('img');
thumbnail.classList.add('img-fluid', 'thumbnail');
thumbnail.src = ''; // Placeholder until upload completes
thumbnailCol.appendChild(thumbnail);
const fileNameCol = document.createElement('div');
fileNameCol.classList.add('col-7');
const fileName = document.createElement('span');
fileName.classList.add('file-name');
fileName.textContent = file.name;
fileNameCol.appendChild(fileName);
const progressCol = document.createElement('div');
progressCol.classList.add('col-3');
const progressBar = document.createElement('div');
progressBar.classList.add('progress');
const progressBarInner = document.createElement('div');
progressBarInner.classList.add('progress-bar');
progressBarInner.setAttribute('role', 'progressbar');
progressBarInner.setAttribute('aria-valuemin', '0');
progressBarInner.setAttribute('aria-valuemax', '100');
progressBarInner.style.width = '0%';
progressBar.appendChild(progressBarInner);
progressCol.appendChild(progressBar);
fileRow.appendChild(thumbnailCol);
fileRow.appendChild(fileNameCol);
fileRow.appendChild(progressCol);
uploadStatus.prepend(fileRow);
uploadFile(file, progressBarInner, thumbnail);
});
}
function uploadFile(file, progressBar, thumbnail) {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
formData.append('csrfmiddlewaretoken', getCookie('csrftoken'));
if (file.type.startsWith('image')) {
formData.append('media_type', 'image');
} else if (file.type.startsWith('video')) {
formData.append('media_type', 'video');
} else {
formData.append('media_type', 'other');
}
formData.append('title', file.name);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressBar.style.width = `${percentComplete}%`;
progressBar.textContent = `${Math.round(percentComplete)}%`;
}
});
xhr.open('POST', document.location.href, true);
xhr.onload = () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
const thumbnailUrl = response.thumbnailUrl;
thumbnail.src = thumbnailUrl;
} else {
progressBar.classList.add('bg-danger');
progressBar.textContent = "Error!";
}
};
xhr.send(formData);
}

View file

@ -216,6 +216,8 @@ QUACKSCAPE_CONTENT_RESOLUTIONS = [
(65536, 32768), (65536, 32768),
] ]
MAX_IMAGE_PIXELS = 34359738368 # 262144x131072 - should be enough, no?
# ffmpeg settings # ffmpeg settings
FFMPEG_OPTIONS = { FFMPEG_OPTIONS = {

View file

@ -1,6 +1,7 @@
from django.db import models from django.db import models
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.conf import settings
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
from PIL import Image from PIL import Image
@ -49,8 +50,12 @@ class Category(models.Model):
class CategoryPermission(models.Model): class CategoryPermission(models.Model):
category = models.ForeignKey(Category, related_name="permissions", on_delete=models.CASCADE) category = models.ForeignKey(
user = models.ForeignKey(get_user_model(), related_name="category_permissions", on_delete=models.CASCADE) Category, related_name="permissions", on_delete=models.CASCADE
)
user = models.ForeignKey(
get_user_model(), related_name="category_permissions", on_delete=models.CASCADE
)
# TODO: Permission levels # TODO: Permission levels
@ -230,7 +235,9 @@ class OriginalImage(OriginalMedia):
def media_type(self) -> str: def media_type(self) -> str:
return "image" return "image"
def save(self): def save(self, *args, **kwargs):
Image.MAX_IMAGE_PIXELS = settings.MAX_IMAGE_PIXELS
if not self.width: if not self.width:
with Image.open(self.file) as img: with Image.open(self.file) as img:
self.width, self.height = img.size self.width, self.height = img.size
@ -244,6 +251,13 @@ class OriginalVideo(OriginalMedia):
def media_type(self) -> str: def media_type(self) -> str:
return "video" return "video"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# TODO: Get and save video resolution
create_video_resolutions(self.id)
class MediaResolution(models.Model): class MediaResolution(models.Model):
id = models.UUIDField(default=uuid.uuid4, primary_key=True) id = models.UUIDField(default=uuid.uuid4, primary_key=True)

View file

@ -4,6 +4,8 @@ from rest_polymorphic.serializers import PolymorphicSerializer
from .models import ( from .models import (
Scene, Scene,
OriginalMedia, OriginalMedia,
OriginalImage,
OriginalVideo,
MediaResolution, MediaResolution,
Element, Element,
TeleportElement, TeleportElement,
@ -84,6 +86,32 @@ class OriginalMediaSerializer(serializers.ModelSerializer):
return None return None
class OriginalImageSerializer(serializers.ModelSerializer):
class Meta:
model = OriginalImage
fields = ["id", "title", "file"]
def to_representation(self, instance):
request = self.context.get("request")
if request and instance.user_has_view_permission(request.user):
return super().to_representation(instance)
else:
return None
class OriginalVideoSerializer(serializers.ModelSerializer):
class Meta:
model = OriginalVideo
fields = ["id", "title", "width", "height", "file"]
def to_representation(self, instance):
request = self.context.get("request")
if request and instance.user_has_view_permission(request.user):
return super().to_representation(instance)
else:
return None
class SceneSerializer(serializers.ModelSerializer): class SceneSerializer(serializers.ModelSerializer):
base_content = OriginalMediaSerializer() base_content = OriginalMediaSerializer()
elements = ElementSerializer(many=True, read_only=True) elements = ElementSerializer(many=True, read_only=True)

View file

@ -15,6 +15,8 @@ import uuid
@shared_task @shared_task
def create_image_resolutions(image: "OriginalImage"): def create_image_resolutions(image: "OriginalImage"):
Image.MAX_IMAGE_PIXELS = settings.MAX_IMAGE_PIXELS
OriginalImage = apps.get_model("tours", "OriginalImage") OriginalImage = apps.get_model("tours", "OriginalImage")
if isinstance(image, (str, uuid.UUID)): if isinstance(image, (str, uuid.UUID)):

View file

@ -0,0 +1,12 @@
{% extends "users/base.html" %}
{% block content %}
<h4>Upload new images or videos</h4>
<div class="container mt-5">
<div class="drop-zone">
<span class="drop-zone__prompt">Drop files here or click to upload</span>
<input type="file" name="myFiles" class="drop-zone__input" multiple>
</div>
<div class="mt-3" id="uploadStatus"></div>
</div>
{% endblock %}

View file

@ -1,4 +1,4 @@
from .views import UserAreaMainView, CategoriesView, CategoryView from .views import UserAreaMainView, CategoriesView, CategoryView, FileUploadView
from django.urls import path from django.urls import path
from django.contrib.auth.views import LogoutView, LoginView from django.contrib.auth.views import LogoutView, LoginView
@ -7,6 +7,7 @@ urlpatterns = [
path('', UserAreaMainView.as_view(), name='user-area-main'), path('', UserAreaMainView.as_view(), name='user-area-main'),
path('categories/', CategoriesView.as_view(), name='categories'), path('categories/', CategoriesView.as_view(), name='categories'),
path('category/<uuid:category>/', CategoryView.as_view(), name='category'), path('category/<uuid:category>/', CategoryView.as_view(), name='category'),
path('category/<uuid:category>/upload/', FileUploadView.as_view(), name='media-upload'),
path('login/', LoginView.as_view(), name='login'), path('login/', LoginView.as_view(), name='login'),
path('logout/', LogoutView.as_view(), name='logout'), path('logout/', LogoutView.as_view(), name='logout'),
] ]

View file

@ -1,7 +1,17 @@
from django.views.generic import TemplateView, ListView, DetailView from django.views.generic import TemplateView, ListView, DetailView
from django.http import Http404 from django.http import Http404
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework.generics import GenericAPIView
from rest_framework import status
from quackscape.tours.models import Category from quackscape.tours.models import Category
from quackscape.tours.serializers import (
OriginalMediaSerializer,
OriginalImageSerializer,
OriginalVideoSerializer,
)
class TitleMixin: class TitleMixin:
@ -54,3 +64,32 @@ class MediaUploadView(TitleMixin, TemplateView):
class CategoryCreateView(TitleMixin, TemplateView): class CategoryCreateView(TitleMixin, TemplateView):
template_name = "users/category_create.html" template_name = "users/category_create.html"
title = "Create Category" title = "Create Category"
class FileUploadView(GenericAPIView):
parser_classes = (MultiPartParser, FormParser)
def get(self, request, *args, **kwargs):
return MediaUploadView.as_view()(request)
def post(self, request, *args, **kwargs):
media_type = request.data.get("media_type")
if media_type == "image":
serializer_class = OriginalImageSerializer
elif media_type == "video":
serializer_class = OriginalVideoSerializer
else:
return Response(
{"error": "Invalid media type"}, status=status.HTTP_400_BAD_REQUEST
)
serializer = serializer_class(data=request.data)
if serializer.is_valid():
instance.category = request.kwargs["category"]
instance = serializer.save()
instance.refresh_from_db()
return Response(OriginalMediaSerializer(instance).data, status=status.HTTP_201_CREATED)
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)