feat: upload file in contact form

This commit is contained in:
mohamad
2026-06-07 17:09:04 +03:30
parent 76185c7434
commit cbc49cd8ae
11 changed files with 536 additions and 6 deletions
+1
View File
@@ -46,5 +46,6 @@ venv.bak/
.dmypy.json .dmypy.json
media/ media/
private_uploads/
staticfiles/ staticfiles/
*.DS_Store *.DS_Store
+44 -2
View File
@@ -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):
+188
View File
@@ -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
+17
View File
@@ -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'],
},
),
]
+26
View File
@@ -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()
+158
View File
@@ -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
View File
@@ -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 = {}
+4
View File
@@ -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
View File
@@ -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);
} }
+20 -1
View File
@@ -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()) {