Harden uploads and enforce language-prefixed routes
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import path, reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import Claim, ClaimLog, Project, SystemSetting
|
||||
from .models import Claim, ClaimLog, Project
|
||||
|
||||
|
||||
class ClaimLogInline(admin.TabularInline):
|
||||
@@ -20,7 +19,7 @@ class ClaimAdmin(admin.ModelAdmin):
|
||||
list_display = ("full_name", "amount", "currency", "project", "status", "paid", "created_at", "submitted_by")
|
||||
list_filter = ("status", "created_at", "project", "paid_at")
|
||||
search_fields = ("full_name", "email", "description")
|
||||
base_readonly = ("created_at", "updated_at", "paid_at", "paid_by", "internal_payments_enabled", "reset_paid_button")
|
||||
base_readonly = ("created_at", "updated_at", "paid_at", "paid_by", "reset_paid_button")
|
||||
readonly_fields = base_readonly
|
||||
inlines = [ClaimLogInline]
|
||||
actions = ("mark_as_paid", "mark_as_unpaid")
|
||||
@@ -29,10 +28,6 @@ class ClaimAdmin(admin.ModelAdmin):
|
||||
def paid(self, obj):
|
||||
return obj.is_paid
|
||||
|
||||
@admin.display(description="Intern betalningshantering på?")
|
||||
def internal_payments_enabled(self, obj):
|
||||
return SystemSetting.internal_payments_active()
|
||||
|
||||
@admin.display(description="Återställ betalning")
|
||||
def reset_paid_button(self, obj):
|
||||
if not obj.is_paid:
|
||||
@@ -115,17 +110,6 @@ class ClaimAdmin(admin.ModelAdmin):
|
||||
return self.base_readonly
|
||||
|
||||
|
||||
@admin.register(SystemSetting)
|
||||
class SystemSettingAdmin(admin.ModelAdmin):
|
||||
list_display = ("internal_payments_enabled", "updated_at")
|
||||
list_display_links = ("updated_at",)
|
||||
list_editable = ("internal_payments_enabled",)
|
||||
actions = None
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return not SystemSetting.objects.exists()
|
||||
|
||||
|
||||
@admin.register(ClaimLog)
|
||||
class ClaimLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("claim", "action", "from_status", "to_status", "performed_by", "created_at")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-09 02:06
|
||||
|
||||
import claims.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('claims', '0006_systemsetting'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='SystemSetting',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='claim',
|
||||
name='receipt',
|
||||
field=models.FileField(blank=True, null=True, upload_to='receipts/', validators=[claims.validators.validate_receipt_file]),
|
||||
),
|
||||
]
|
||||
@@ -5,6 +5,8 @@ from django.core.files.storage import default_storage
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .validators import validate_receipt_file
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
@@ -51,7 +53,12 @@ class Claim(models.Model):
|
||||
)
|
||||
description = models.TextField(help_text=_("Describe what the reimbursement is for"))
|
||||
account_number = models.CharField(max_length=50)
|
||||
receipt = models.FileField(upload_to="receipts/", blank=True, null=True)
|
||||
receipt = models.FileField(
|
||||
upload_to="receipts/",
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[validate_receipt_file],
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
Project,
|
||||
null=True,
|
||||
@@ -141,23 +148,3 @@ class ClaimLog(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.get_action_display()} ({self.created_at:%Y-%m-%d %H:%M})"
|
||||
|
||||
|
||||
class SystemSetting(models.Model):
|
||||
internal_payments_enabled = models.BooleanField(default=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Systeminställning"
|
||||
verbose_name_plural = "Systeminställningar"
|
||||
|
||||
def __str__(self):
|
||||
return "Systeminställningar"
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls):
|
||||
obj, _ = cls.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def internal_payments_active(cls):
|
||||
return cls.get_solo().internal_payments_enabled
|
||||
|
||||
@@ -1,3 +1,67 @@
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
# Create your tests here.
|
||||
from .validators import validate_receipt_file
|
||||
from .views import SubmitClaimView
|
||||
|
||||
|
||||
class ReceiptValidatorTests(TestCase):
|
||||
def test_accepts_valid_pdf(self):
|
||||
file_obj = SimpleUploadedFile(
|
||||
"receipt.pdf",
|
||||
b"%PDF-1.4\nsample",
|
||||
content_type="application/pdf",
|
||||
)
|
||||
try:
|
||||
validate_receipt_file(file_obj)
|
||||
except ValidationError as exc: # pragma: no cover - explicit failure message
|
||||
self.fail(f"Valid PDF rejected: {exc}")
|
||||
|
||||
def test_rejects_disallowed_extension(self):
|
||||
file_obj = SimpleUploadedFile(
|
||||
"script.exe",
|
||||
b"MZ fake exe",
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_receipt_file(file_obj)
|
||||
|
||||
@override_settings(CLAIMS_MAX_RECEIPT_BYTES=1024)
|
||||
def test_rejects_too_large_file(self):
|
||||
big_payload = b"%PDF-1.4\n" + b"a" * 2048
|
||||
file_obj = SimpleUploadedFile(
|
||||
"large.pdf",
|
||||
big_payload,
|
||||
content_type="application/pdf",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_receipt_file(file_obj)
|
||||
|
||||
def test_rejects_signature_mismatch(self):
|
||||
file_obj = SimpleUploadedFile(
|
||||
"fake.pdf",
|
||||
b"\x89PNG\r\n\x1a\nnot a pdf",
|
||||
content_type="application/pdf",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_receipt_file(file_obj)
|
||||
|
||||
|
||||
class ClaimFormsetLimitTests(TestCase):
|
||||
def test_default_formset_has_single_row(self):
|
||||
view = SubmitClaimView()
|
||||
formset = view.build_formset(extra=1)
|
||||
self.assertEqual(formset.total_form_count(), 1)
|
||||
|
||||
def test_cannot_submit_more_than_max_forms(self):
|
||||
view = SubmitClaimView()
|
||||
data = {
|
||||
"claim_lines-TOTAL_FORMS": "6",
|
||||
"claim_lines-INITIAL_FORMS": "0",
|
||||
"claim_lines-MIN_NUM_FORMS": "1",
|
||||
"claim_lines-MAX_NUM_FORMS": "5",
|
||||
}
|
||||
formset = view.build_formset(data=data)
|
||||
self.assertFalse(formset.is_valid())
|
||||
self.assertTrue(formset.non_form_errors())
|
||||
|
||||
115
claims/validators.py
Normal file
115
claims/validators.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import io
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
DEFAULT_ALLOWED_EXTENSIONS = ("pdf", "png", "jpg", "jpeg")
|
||||
DEFAULT_ALLOWED_CONTENT_TYPES = ("application/pdf", "image/png", "image/jpeg")
|
||||
|
||||
PDF_SIGNATURE = b"%PDF-"
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
JPEG_PREFIX = b"\xff\xd8"
|
||||
|
||||
|
||||
def _peek(file_obj, length=8):
|
||||
"""Read the first bytes of a file without consuming the stream."""
|
||||
stream = getattr(file_obj, "file", file_obj)
|
||||
if isinstance(stream, io.BytesIO):
|
||||
pos = stream.tell()
|
||||
stream.seek(0)
|
||||
data = stream.read(length)
|
||||
stream.seek(pos)
|
||||
return data
|
||||
|
||||
try:
|
||||
pos = stream.tell()
|
||||
except (AttributeError, OSError):
|
||||
pos = None
|
||||
try:
|
||||
stream.seek(0)
|
||||
data = stream.read(length)
|
||||
finally:
|
||||
if pos is not None:
|
||||
stream.seek(pos)
|
||||
else:
|
||||
try:
|
||||
stream.seek(0)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def _allowed_extensions():
|
||||
exts = getattr(settings, "CLAIMS_ALLOWED_RECEIPT_EXTENSIONS", DEFAULT_ALLOWED_EXTENSIONS)
|
||||
normalized = {ext.lower() for ext in exts}
|
||||
return normalized or set(DEFAULT_ALLOWED_EXTENSIONS)
|
||||
|
||||
|
||||
def _allowed_content_types():
|
||||
cts = getattr(settings, "CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES", DEFAULT_ALLOWED_CONTENT_TYPES)
|
||||
normalized = {ct.lower() for ct in cts}
|
||||
return normalized or set(DEFAULT_ALLOWED_CONTENT_TYPES)
|
||||
|
||||
|
||||
def _max_file_size():
|
||||
return int(getattr(settings, "CLAIMS_MAX_RECEIPT_BYTES", 10 * 1024 * 1024))
|
||||
|
||||
|
||||
def _extension(validated_file):
|
||||
name = validated_file.name or ""
|
||||
if "." not in name:
|
||||
return ""
|
||||
return name.rsplit(".", 1)[-1].lower()
|
||||
|
||||
|
||||
def _signature_matches(ext, header):
|
||||
if not header:
|
||||
return False
|
||||
if ext == "pdf":
|
||||
return header.startswith(PDF_SIGNATURE)
|
||||
if ext == "png":
|
||||
return header.startswith(PNG_SIGNATURE)
|
||||
if ext in {"jpg", "jpeg"}:
|
||||
return header.startswith(JPEG_PREFIX)
|
||||
return False
|
||||
|
||||
|
||||
def validate_receipt_file(uploaded_file):
|
||||
"""Ensure uploaded receipts comply with size/format requirements."""
|
||||
if not uploaded_file:
|
||||
return
|
||||
|
||||
max_bytes = _max_file_size()
|
||||
if uploaded_file.size > max_bytes:
|
||||
max_mb = round(max_bytes / (1024 * 1024), 2)
|
||||
raise ValidationError(
|
||||
_("Kvitton får vara max %(size)s MB."),
|
||||
code="file_too_large",
|
||||
params={"size": max_mb},
|
||||
)
|
||||
|
||||
ext = _extension(uploaded_file)
|
||||
extensions = _allowed_extensions()
|
||||
if ext not in extensions:
|
||||
raise ValidationError(
|
||||
_("Otillåtet filformat. Tillåtna format är %(formats)s."),
|
||||
code="invalid_extension",
|
||||
params={"formats": ", ".join(sorted(extensions))},
|
||||
)
|
||||
|
||||
content_type = getattr(uploaded_file, "content_type", "")
|
||||
allowed_types = _allowed_content_types()
|
||||
if content_type and content_type.lower() not in allowed_types:
|
||||
raise ValidationError(
|
||||
_("Otillåten MIME-typ: %(type)s."),
|
||||
code="invalid_mime",
|
||||
params={"type": content_type},
|
||||
)
|
||||
|
||||
header = _peek(uploaded_file, length=8)
|
||||
if not _signature_matches(ext, header):
|
||||
raise ValidationError(
|
||||
_("Filens innehåll matchar inte förväntat format."),
|
||||
code="invalid_signature",
|
||||
)
|
||||
@@ -20,7 +20,7 @@ from .forms import (
|
||||
UserPermissionForm,
|
||||
)
|
||||
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
|
||||
from .models import Claim, ClaimLog, SystemSetting
|
||||
from .models import Claim, ClaimLog
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -31,17 +31,21 @@ class SubmitClaimView(View):
|
||||
|
||||
def get_extra_forms(self):
|
||||
try:
|
||||
count = int(self.request.GET.get("forms", 2))
|
||||
count = int(self.request.GET.get("forms", 1))
|
||||
except (TypeError, ValueError):
|
||||
count = 2
|
||||
count = 1
|
||||
return max(1, min(count, self.max_extra_forms))
|
||||
|
||||
def build_formset(self, *, data=None, files=None, extra=0):
|
||||
extra_forms = max(0, extra - 1)
|
||||
FormSet = formset_factory(
|
||||
ClaimLineForm,
|
||||
extra=extra,
|
||||
extra=extra_forms,
|
||||
min_num=1,
|
||||
max_num=self.max_extra_forms,
|
||||
absolute_max=self.max_extra_forms,
|
||||
validate_min=True,
|
||||
validate_max=True,
|
||||
)
|
||||
return FormSet(data=data, files=files, prefix="claim_lines")
|
||||
|
||||
@@ -154,7 +158,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
context["status_choices"] = Claim.Status.choices
|
||||
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
|
||||
context["can_change"] = self.request.user.has_perm("claims.change_claim")
|
||||
context["payments_enabled"] = SystemSetting.internal_payments_active()
|
||||
context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False)
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@@ -202,7 +206,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
def _handle_payment(self, request):
|
||||
if not SystemSetting.internal_payments_active():
|
||||
if not getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False):
|
||||
messages.error(request, _("Betalningshantering är inte aktiverad."))
|
||||
return redirect(request.get_full_path())
|
||||
if not request.user.has_perm("claims.change_claim"):
|
||||
|
||||
Reference in New Issue
Block a user