feat: upload file in contact form
This commit is contained in:
@@ -46,5 +46,6 @@ venv.bak/
|
|||||||
.dmypy.json
|
.dmypy.json
|
||||||
|
|
||||||
media/
|
media/
|
||||||
|
private_uploads/
|
||||||
staticfiles/
|
staticfiles/
|
||||||
*.DS_Store
|
*.DS_Store
|
||||||
+44
-2
@@ -1,11 +1,13 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import FileResponse, Http404, HttpResponseRedirect
|
||||||
from django.urls import reverse
|
from django.urls import path, reverse
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
AboutSection,
|
AboutSection,
|
||||||
AboutSectionItem,
|
AboutSectionItem,
|
||||||
ContactSubmission,
|
ContactSubmission,
|
||||||
|
ContactSubmissionAttachment,
|
||||||
CustomPage,
|
CustomPage,
|
||||||
CustomPageSection,
|
CustomPageSection,
|
||||||
CustomPageSectionItem,
|
CustomPageSectionItem,
|
||||||
@@ -83,6 +85,21 @@ class AboutSectionAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionAttachmentInline(admin.TabularInline):
|
||||||
|
model = ContactSubmissionAttachment
|
||||||
|
extra = 0
|
||||||
|
can_delete = False
|
||||||
|
fields = ("original_filename", "attachment_link", "uploaded_at")
|
||||||
|
readonly_fields = ("original_filename", "attachment_link", "uploaded_at")
|
||||||
|
|
||||||
|
@admin.display(description="File")
|
||||||
|
def attachment_link(self, obj):
|
||||||
|
if not obj.file:
|
||||||
|
return "—"
|
||||||
|
url = reverse("admin:pages_contactattachment_download", args=[obj.pk])
|
||||||
|
return format_html('<a href="{}">Download</a>', url)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ContactSubmission)
|
@admin.register(ContactSubmission)
|
||||||
class ContactSubmissionAdmin(admin.ModelAdmin):
|
class ContactSubmissionAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "title", "email", "submitted_at", "is_read")
|
list_display = ("name", "title", "email", "submitted_at", "is_read")
|
||||||
@@ -90,11 +107,36 @@ class ContactSubmissionAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("name", "title", "description", "email")
|
search_fields = ("name", "title", "description", "email")
|
||||||
list_editable = ("is_read",)
|
list_editable = ("is_read",)
|
||||||
readonly_fields = ("name", "title", "description", "email", "submitted_at")
|
readonly_fields = ("name", "title", "description", "email", "submitted_at")
|
||||||
|
inlines = [ContactSubmissionAttachmentInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name", "title", "email", "description")}),
|
(None, {"fields": ("name", "title", "email", "description")}),
|
||||||
("Meta", {"fields": ("submitted_at", "is_read")}),
|
("Meta", {"fields": ("submitted_at", "is_read")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
urls = super().get_urls()
|
||||||
|
custom_urls = [
|
||||||
|
path(
|
||||||
|
"attachment/<int:attachment_id>/download/",
|
||||||
|
self.admin_site.admin_view(self.download_attachment),
|
||||||
|
name="pages_contactattachment_download",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return custom_urls + urls
|
||||||
|
|
||||||
|
def download_attachment(self, request, attachment_id):
|
||||||
|
try:
|
||||||
|
attachment = ContactSubmissionAttachment.objects.get(pk=attachment_id)
|
||||||
|
except ContactSubmissionAttachment.DoesNotExist as exc:
|
||||||
|
raise Http404 from exc
|
||||||
|
if not attachment.file:
|
||||||
|
raise Http404
|
||||||
|
return FileResponse(
|
||||||
|
attachment.file.open("rb"),
|
||||||
|
as_attachment=True,
|
||||||
|
filename=attachment.original_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(FAQEntry)
|
@admin.register(FAQEntry)
|
||||||
class FAQEntryAdmin(admin.ModelAdmin):
|
class FAQEntryAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
|
from django.utils.text import get_valid_filename
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = frozenset(
|
||||||
|
{
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".zip",
|
||||||
|
".log",
|
||||||
|
".txt",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
|
||||||
|
TEXT_EXTENSIONS = frozenset({".log", ".txt"})
|
||||||
|
ARCHIVE_EXTENSIONS = frozenset({".zip"})
|
||||||
|
|
||||||
|
MAX_FILE_SIZE = getattr(settings, "CONTACT_ATTACHMENT_MAX_SIZE", 10 * 1024 * 1024)
|
||||||
|
MAX_ATTACHMENTS = getattr(settings, "CONTACT_ATTACHMENT_MAX_COUNT", 3)
|
||||||
|
MAX_ZIP_ENTRIES = 100
|
||||||
|
MAX_ZIP_UNCOMPRESSED_SIZE = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ContactAttachmentStorage(FileSystemStorage):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(location=settings.CONTACT_UPLOAD_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
contact_attachment_storage = ContactAttachmentStorage()
|
||||||
|
|
||||||
|
|
||||||
|
def contact_attachment_upload_to(instance, _filename):
|
||||||
|
ext = os.path.splitext(instance.original_filename)[1].lower()
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
ext = ".bin"
|
||||||
|
subdir = str(instance.submission_id) if instance.submission_id else "pending"
|
||||||
|
return f"{subdir}/{uuid.uuid4().hex}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extension(filename):
|
||||||
|
return os.path.splitext(filename)[1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_header(uploaded_file, size=32):
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
header = uploaded_file.read(size)
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
return header
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_magic(header, ext):
|
||||||
|
if ext == ".png" and not header.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
raise ValidationError("File content does not match its type.")
|
||||||
|
if ext in (".jpg", ".jpeg") and not header.startswith(b"\xff\xd8\xff"):
|
||||||
|
raise ValidationError("File content does not match its type.")
|
||||||
|
if ext == ".gif" and not (
|
||||||
|
header.startswith(b"GIF87a") or header.startswith(b"GIF89a")
|
||||||
|
):
|
||||||
|
raise ValidationError("File content does not match its type.")
|
||||||
|
if ext == ".webp" and not (
|
||||||
|
len(header) >= 12 and header[0:4] == b"RIFF" and header[8:12] == b"WEBP"
|
||||||
|
):
|
||||||
|
raise ValidationError("File content does not match its type.")
|
||||||
|
if ext == ".bmp" and not header.startswith(b"BM"):
|
||||||
|
raise ValidationError("File content does not match its type.")
|
||||||
|
if ext == ".zip" and not (
|
||||||
|
header.startswith(b"PK\x03\x04")
|
||||||
|
or header.startswith(b"PK\x05\x06")
|
||||||
|
or header.startswith(b"PK\x07\x08")
|
||||||
|
):
|
||||||
|
raise ValidationError("File content does not match its type.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_image(uploaded_file):
|
||||||
|
try:
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
with Image.open(uploaded_file) as img:
|
||||||
|
img.verify()
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
with Image.open(uploaded_file) as img:
|
||||||
|
img.load()
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValidationError("Invalid or corrupted image file.") from exc
|
||||||
|
finally:
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_text(uploaded_file):
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
data = uploaded_file.read()
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
if b"\x00" in data:
|
||||||
|
raise ValidationError("Text files must not contain binary data.")
|
||||||
|
try:
|
||||||
|
data.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
try:
|
||||||
|
data.decode("latin-1")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValidationError("Text file is not valid UTF-8 or Latin-1.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_zip(uploaded_file):
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
if not zipfile.is_zipfile(uploaded_file):
|
||||||
|
raise ValidationError("Invalid ZIP archive.")
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
|
||||||
|
total_uncompressed = 0
|
||||||
|
entry_count = 0
|
||||||
|
|
||||||
|
with zipfile.ZipFile(uploaded_file, "r") as archive:
|
||||||
|
for info in archive.infolist():
|
||||||
|
entry_count += 1
|
||||||
|
if entry_count > MAX_ZIP_ENTRIES:
|
||||||
|
raise ValidationError("ZIP archive contains too many files.")
|
||||||
|
|
||||||
|
name = info.filename
|
||||||
|
if name.startswith("/") or ".." in PurePosixPath(name).parts:
|
||||||
|
raise ValidationError("ZIP archive contains unsafe paths.")
|
||||||
|
|
||||||
|
if info.flag_bits & 0x1:
|
||||||
|
raise ValidationError("Encrypted ZIP archives are not allowed.")
|
||||||
|
|
||||||
|
total_uncompressed += info.file_size
|
||||||
|
if total_uncompressed > MAX_ZIP_UNCOMPRESSED_SIZE:
|
||||||
|
raise ValidationError("ZIP archive uncompressed size is too large.")
|
||||||
|
|
||||||
|
uploaded_file.seek(0)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_contact_attachment(uploaded_file):
|
||||||
|
if uploaded_file.size > MAX_FILE_SIZE:
|
||||||
|
max_mb = MAX_FILE_SIZE // (1024 * 1024)
|
||||||
|
raise ValidationError(f"File exceeds the maximum size of {max_mb} MB.")
|
||||||
|
|
||||||
|
name = get_valid_filename(os.path.basename(uploaded_file.name))
|
||||||
|
if not name:
|
||||||
|
raise ValidationError("Invalid file name.")
|
||||||
|
|
||||||
|
ext = _extension(name)
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise ValidationError(
|
||||||
|
"File type not allowed. Permitted: images (PNG, JPG, GIF, WebP, BMP), "
|
||||||
|
"ZIP, LOG, or TXT."
|
||||||
|
)
|
||||||
|
|
||||||
|
header = _read_header(uploaded_file)
|
||||||
|
if ext in IMAGE_EXTENSIONS or ext in ARCHIVE_EXTENSIONS:
|
||||||
|
_validate_magic(header, ext)
|
||||||
|
|
||||||
|
if ext in IMAGE_EXTENSIONS:
|
||||||
|
_validate_image(uploaded_file)
|
||||||
|
elif ext in TEXT_EXTENSIONS:
|
||||||
|
_validate_text(uploaded_file)
|
||||||
|
elif ext in ARCHIVE_EXTENSIONS:
|
||||||
|
_validate_zip(uploaded_file)
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def validate_contact_attachments(files):
|
||||||
|
if not files:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(files) > MAX_ATTACHMENTS:
|
||||||
|
raise ValidationError(f"You can attach at most {MAX_ATTACHMENTS} files.")
|
||||||
|
|
||||||
|
validated = []
|
||||||
|
for uploaded_file in files:
|
||||||
|
if not isinstance(uploaded_file, UploadedFile):
|
||||||
|
raise ValidationError("Invalid upload.")
|
||||||
|
original_name = validate_contact_attachment(uploaded_file)
|
||||||
|
validated.append((uploaded_file, original_name))
|
||||||
|
return validated
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from .contact_uploads import validate_contact_attachments
|
||||||
from .models import ContactSubmission
|
from .models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
class ContactForm(forms.ModelForm):
|
class ContactForm(forms.ModelForm):
|
||||||
|
attachments = forms.Field(required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ContactSubmission
|
model = ContactSubmission
|
||||||
fields = ["name", "title", "description", "email"]
|
fields = ["name", "title", "description", "email"]
|
||||||
@@ -22,3 +26,16 @@ class ContactForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, file_list=None, **kwargs):
|
||||||
|
self.file_list = file_list
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
try:
|
||||||
|
cleaned_data["attachments"] = validate_contact_attachments(self.file_list)
|
||||||
|
except ValidationError as exc:
|
||||||
|
self.add_error("attachments", exc)
|
||||||
|
cleaned_data["attachments"] = []
|
||||||
|
return cleaned_data
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2026-06-07 13:23
|
||||||
|
|
||||||
|
import apps.pages.contact_uploads
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pages', '0012_aboutsectionitem_icon'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContactSubmissionAttachment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file', models.FileField(storage=apps.pages.contact_uploads.ContactAttachmentStorage(), upload_to=apps.pages.contact_uploads.contact_attachment_upload_to)),
|
||||||
|
('original_filename', models.CharField(max_length=255)),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='pages.contactsubmission')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Contact Attachment',
|
||||||
|
'verbose_name_plural': 'Contact Attachments',
|
||||||
|
'ordering': ['uploaded_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -3,6 +3,10 @@ from django.db import models
|
|||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
|
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
|
||||||
|
from apps.pages.contact_uploads import (
|
||||||
|
contact_attachment_storage,
|
||||||
|
contact_attachment_upload_to,
|
||||||
|
)
|
||||||
|
|
||||||
RESERVED_PAGE_SLUGS = frozenset({
|
RESERVED_PAGE_SLUGS = frozenset({
|
||||||
"admin",
|
"admin",
|
||||||
@@ -177,6 +181,28 @@ class ContactSubmission(models.Model):
|
|||||||
return f"{self.name} — {self.title}"
|
return f"{self.name} — {self.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionAttachment(models.Model):
|
||||||
|
submission = models.ForeignKey(
|
||||||
|
ContactSubmission,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="attachments",
|
||||||
|
)
|
||||||
|
file = models.FileField(
|
||||||
|
upload_to=contact_attachment_upload_to,
|
||||||
|
storage=contact_attachment_storage,
|
||||||
|
)
|
||||||
|
original_filename = models.CharField(max_length=255)
|
||||||
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["uploaded_at"]
|
||||||
|
verbose_name = "Contact Attachment"
|
||||||
|
verbose_name_plural = "Contact Attachments"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.original_filename
|
||||||
|
|
||||||
|
|
||||||
class FAQEntry(models.Model):
|
class FAQEntry(models.Model):
|
||||||
question = models.CharField(max_length=500)
|
question = models.CharField(max_length=500)
|
||||||
answer = models.TextField()
|
answer = models.TextField()
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import Client, TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from apps.pages.contact_uploads import (
|
||||||
|
MAX_ATTACHMENTS,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
validate_contact_attachments,
|
||||||
|
)
|
||||||
|
from apps.pages.models import ContactSubmission, ContactSubmissionAttachment
|
||||||
|
|
||||||
|
|
||||||
|
def _png_file(name="screenshot.png"):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (8, 8), color="red").save(buffer, format="PNG")
|
||||||
|
buffer.seek(0)
|
||||||
|
return SimpleUploadedFile(name, buffer.read(), content_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_file(name="logs.zip", entries=None):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for entry_name, content in (entries or {"app.log": "line one\n"}).items():
|
||||||
|
archive.writestr(entry_name, content)
|
||||||
|
buffer.seek(0)
|
||||||
|
return SimpleUploadedFile(name, buffer.read(), content_type="application/zip")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUploadValidationTest(TestCase):
|
||||||
|
def test_accepts_valid_png(self):
|
||||||
|
validated = validate_contact_attachments([_png_file()])
|
||||||
|
self.assertEqual(len(validated), 1)
|
||||||
|
self.assertEqual(validated[0][1], "screenshot.png")
|
||||||
|
|
||||||
|
def test_accepts_valid_zip(self):
|
||||||
|
validated = validate_contact_attachments([_zip_file()])
|
||||||
|
self.assertEqual(len(validated), 1)
|
||||||
|
|
||||||
|
def test_accepts_valid_text_log(self):
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"error.log",
|
||||||
|
b"2026-06-07 ERROR something failed\n",
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
validated = validate_contact_attachments([uploaded])
|
||||||
|
self.assertEqual(validated[0][1], "error.log")
|
||||||
|
|
||||||
|
def test_rejects_executable_extension(self):
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"malware.exe",
|
||||||
|
b"MZfake",
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments([uploaded])
|
||||||
|
|
||||||
|
def test_rejects_php_disguised_as_png(self):
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"image.png",
|
||||||
|
b"<?php echo 'bad'; ?>",
|
||||||
|
content_type="image/png",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments([uploaded])
|
||||||
|
|
||||||
|
def test_rejects_oversized_file(self):
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"big.log",
|
||||||
|
b"x" * (MAX_FILE_SIZE + 1),
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments([uploaded])
|
||||||
|
|
||||||
|
def test_rejects_too_many_files(self):
|
||||||
|
files = [_png_file(f"shot-{index}.png") for index in range(MAX_ATTACHMENTS + 1)]
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments(files)
|
||||||
|
|
||||||
|
def test_rejects_zip_with_path_traversal(self):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w") as archive:
|
||||||
|
archive.writestr("../escape.txt", "bad")
|
||||||
|
buffer.seek(0)
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"bad.zip",
|
||||||
|
buffer.read(),
|
||||||
|
content_type="application/zip",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments([uploaded])
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CONTACT_UPLOAD_ROOT=__import__("pathlib").Path(__file__).resolve().parents[3]
|
||||||
|
/ "test_private_uploads"
|
||||||
|
)
|
||||||
|
class ContactViewUploadTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client(enforce_csrf_checks=True)
|
||||||
|
self.url = reverse("pages:contact")
|
||||||
|
|
||||||
|
def _start_session(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.csrf_token = response.cookies["csrftoken"].value
|
||||||
|
captcha_question = response.context["captcha_question"]
|
||||||
|
left, right = captcha_question.split(" + ")
|
||||||
|
return int(left) + int(right)
|
||||||
|
|
||||||
|
def _post_contact(self, captcha_answer, attachments=None, extra=None):
|
||||||
|
payload = {
|
||||||
|
"name": "Test User",
|
||||||
|
"title": "Upload test",
|
||||||
|
"description": "Testing attachments",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"captcha_answer": captcha_answer,
|
||||||
|
}
|
||||||
|
if attachments is not None:
|
||||||
|
payload["attachments"] = attachments
|
||||||
|
if extra:
|
||||||
|
payload.update(extra)
|
||||||
|
return self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=payload,
|
||||||
|
HTTP_X_CSRFTOKEN=self.csrf_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_contact_submission_with_png_attachment(self):
|
||||||
|
captcha_answer = self._start_session()
|
||||||
|
response = self._post_contact(captcha_answer, attachments=_png_file())
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.json()["success"])
|
||||||
|
submission = ContactSubmission.objects.get(title="Upload test")
|
||||||
|
self.assertEqual(submission.attachments.count(), 1)
|
||||||
|
attachment = submission.attachments.first()
|
||||||
|
self.assertEqual(attachment.original_filename, "screenshot.png")
|
||||||
|
self.assertTrue(attachment.file.storage.exists(attachment.file.name))
|
||||||
|
|
||||||
|
def test_contact_submission_rejects_invalid_attachment(self):
|
||||||
|
captcha_answer = self._start_session()
|
||||||
|
response = self._post_contact(
|
||||||
|
captcha_answer,
|
||||||
|
attachments=SimpleUploadedFile(
|
||||||
|
"bad.exe",
|
||||||
|
b"MZ",
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertFalse(response.json()["success"])
|
||||||
|
self.assertIn("attachments", response.json()["errors"])
|
||||||
|
self.assertEqual(ContactSubmission.objects.count(), 0)
|
||||||
|
self.assertEqual(ContactSubmissionAttachment.objects.count(), 0)
|
||||||
+14
-2
@@ -12,6 +12,7 @@ from .forms import ContactForm
|
|||||||
from .models import (
|
from .models import (
|
||||||
AboutSection,
|
AboutSection,
|
||||||
ContactSubmission,
|
ContactSubmission,
|
||||||
|
ContactSubmissionAttachment,
|
||||||
CustomPage,
|
CustomPage,
|
||||||
FAQEntry,
|
FAQEntry,
|
||||||
HeroSection,
|
HeroSection,
|
||||||
@@ -87,7 +88,10 @@ class ContactView(View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
form = ContactForm(request.POST)
|
form = ContactForm(
|
||||||
|
request.POST,
|
||||||
|
file_list=request.FILES.getlist("attachments"),
|
||||||
|
)
|
||||||
expected = request.session.get("captcha_answer")
|
expected = request.session.get("captcha_answer")
|
||||||
captcha_question = self._new_captcha(request)
|
captcha_question = self._new_captcha(request)
|
||||||
|
|
||||||
@@ -98,7 +102,15 @@ class ContactView(View):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if form.is_valid() and captcha_ok:
|
if form.is_valid() and captcha_ok:
|
||||||
form.save()
|
submission = form.save()
|
||||||
|
for uploaded_file, original_name in form.cleaned_data.get(
|
||||||
|
"attachments", []
|
||||||
|
):
|
||||||
|
attachment = ContactSubmissionAttachment(
|
||||||
|
submission=submission,
|
||||||
|
original_filename=original_name,
|
||||||
|
)
|
||||||
|
attachment.file.save(original_name, uploaded_file, save=True)
|
||||||
return JsonResponse({"success": True})
|
return JsonResponse({"success": True})
|
||||||
|
|
||||||
errors: dict = {}
|
errors: dict = {}
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
|||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
|
CONTACT_UPLOAD_ROOT = BASE_DIR / "private_uploads" / "contact"
|
||||||
|
CONTACT_ATTACHMENT_MAX_SIZE = 10 * 1024 * 1024
|
||||||
|
CONTACT_ATTACHMENT_MAX_COUNT = 3
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
|
|||||||
+34
-1
@@ -2530,6 +2530,38 @@ a.citation-count-badge:hover {
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted, var(--text-secondary));
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-file-list {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="file"] {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="file"]::file-selector-button {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: rgba(79, 142, 247, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group textarea,
|
.form-group textarea,
|
||||||
.form-group select {
|
.form-group select {
|
||||||
@@ -2560,7 +2592,8 @@ a.citation-count-badge:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-group.has-error input,
|
.form-group.has-error input,
|
||||||
.form-group.has-error textarea {
|
.form-group.has-error textarea,
|
||||||
|
.form-group.has-error input[type="file"] {
|
||||||
border-color: rgba(239, 68, 68, 0.5);
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<div class="contact-form-wrap glass-card fade-in">
|
<div class="contact-form-wrap glass-card fade-in">
|
||||||
<h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2>
|
<h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2>
|
||||||
<form id="contactForm" novalidate action="{% url 'pages:contact' %}" method="post">
|
<form id="contactForm" novalidate action="{% url 'pages:contact' %}" method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -53,6 +53,16 @@
|
|||||||
<span class="form-field-error" data-field="email"></span>
|
<span class="form-field-error" data-field="email"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="id_attachments">Attachments <span class="optional-label">(optional)</span></label>
|
||||||
|
<input type="file" name="attachments" id="id_attachments" multiple
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.webp,.bmp,.zip,.log,.txt"
|
||||||
|
class="file-input" />
|
||||||
|
<p class="form-hint">Images, ZIP, or log/text files — up to 3 files, 10 MB each.</p>
|
||||||
|
<p class="form-file-list" id="attachmentFileList" aria-live="polite"></p>
|
||||||
|
<span class="form-field-error" data-field="attachments"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group form-group--captcha">
|
<div class="form-group form-group--captcha">
|
||||||
<label for="id_captcha">
|
<label for="id_captcha">
|
||||||
Verification: what is <strong id="captchaQuestion">{{ captcha_question }}</strong>?
|
Verification: what is <strong id="captchaQuestion">{{ captcha_question }}</strong>?
|
||||||
@@ -178,6 +188,8 @@
|
|||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
const emailField = form.querySelector('#id_email');
|
const emailField = form.querySelector('#id_email');
|
||||||
|
const fileInput = form.querySelector('#id_attachments');
|
||||||
|
const fileList = form.querySelector('#attachmentFileList');
|
||||||
const submitBtn = form.querySelector('#submitBtn');
|
const submitBtn = form.querySelector('#submitBtn');
|
||||||
const btnLabel = submitBtn.querySelector('.btn-label');
|
const btnLabel = submitBtn.querySelector('.btn-label');
|
||||||
const btnSpinner = submitBtn.querySelector('.btn-spinner');
|
const btnSpinner = submitBtn.querySelector('.btn-spinner');
|
||||||
@@ -226,6 +238,7 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
if (fileList) fileList.textContent = '';
|
||||||
showModal('successModal');
|
showModal('successModal');
|
||||||
} else {
|
} else {
|
||||||
if (data.errors) showErrors(data.errors);
|
if (data.errors) showErrors(data.errors);
|
||||||
@@ -248,6 +261,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileInput?.addEventListener('change', () => {
|
||||||
|
if (!fileList) return;
|
||||||
|
const names = [...fileInput.files].map(file => file.name);
|
||||||
|
fileList.textContent = names.length ? names.join(', ') : '';
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', e => {
|
form.addEventListener('submit', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!emailField.value.trim()) {
|
if (!emailField.value.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user