Harden uploads and enforce language-prefixed routes

This commit is contained in:
Victor Andersson
2025-11-09 10:03:23 +01:00
parent 3835be3c17
commit 79f5cb8ff3
10 changed files with 263 additions and 66 deletions

View File

@@ -22,7 +22,7 @@ Bygg ett webbaserat system för hantering av utlägg (”claims”) åt en organ
10. Claims ska kopplas till ett projekt/evenemang; projekten hanteras via Django admin.
11. Offentliga sidor ska använda Tailwind-baserade komponenter (CDN är okej) med minimalistisk layout. Claim-formuläret ska erbjuda klient-side kontroll för antal rader (plus/minus) utan sidladdning och återanvända formsetets tomma form som mall.
12. Adminvyn för claims ska spegla samma designprinciper (kort per claim, statuschippar, loggtimeline och inlinebeslut).
13. Hantering av utbetalningar i UI är bakom flaggan `CLAIMS_ENABLE_INTERNAL_PAYMENTS` och kan även togglas via Django admin (modell `SystemSetting`). När den är på ska godkända claims få en summeringssektion med tydlig info (namn, belopp, kontonr) och en "Betala"-knapp som markerar posten som betald (med logg och markerad betalstatus). När flaggan är av saknas knappen och admins instrueras att hantera betalning externt.
13. Hantering av utbetalningar i UI är bakom flaggan `CLAIMS_ENABLE_INTERNAL_PAYMENTS` (styrd via miljövariabel). När den är på ska godkända claims få en summeringssektion med tydlig info (namn, belopp, kontonr) och en "Betala"-knapp som markerar posten som betald (med logg och markerad betalstatus). När flaggan är av saknas knappen och admins instrueras att hantera betalning externt.
14. När ett utlägg markerats som betalt ska beslut/status vara låst (ingen uppdatering av kommentar eller status i UI eller Django admin). Reset av betalstatus sker endast via admin-knappen.
15. Filuppladdningar ska alltid få säkra, unika namn (UUID i `receipts/`), och originalnamn ska inte exponeras.
16. För e-postaviseringar: använd `CLAIMS_EMAIL_ENABLED` (default false) och miljövariablerna `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `CLAIMS_EMAIL_FROM`, `CLAIMS_ADMIN_NOTIFICATION_EMAIL`. När flaggan är av ska koden inte försöka skicka mejl (men gärna logga att aviseringar är inaktiverade).

View File

@@ -10,14 +10,14 @@ Modern Django/Tailwind-baserad portal för att ta emot, granska och betala utlä
3. `uv run python manage.py createsuperuser`
4. `uv run python manage.py runserver`
Nyckel-URLer (default):
- Offentligt formulär `GET /claims/new/`
- Bekräftelsesida `GET /claims/submitted/`
- Adminlista `GET /claims/admin/`
- Mina utlägg `GET /claims/mine/`
- Användarhantering `GET /claims/users/`
- Export-placeholder `GET /claims/export/`
- Auth `GET /accounts/login|logout/`
Nyckel-URLer (språkprefixed):
- Offentligt formulär `GET /sv/claims/new/` eller `/en/claims/new/`
- Bekräftelsesida `GET /sv/claims/submitted/`
- Adminlista `GET /sv/claims/admin/`
- Mina utlägg `GET /sv/claims/mine/`
- Användarhantering `GET /sv/claims/users/`
- Export-placeholder `GET /sv/claims/export/`
- Auth `GET /sv/accounts/login|logout/`
---
@@ -48,16 +48,19 @@ Nyckel-URLer (default):
### 4. Viktiga inställningar
| Variabel | Default | Beskrivning |
| --- | --- | --- |
| `CLAIMS_ENABLE_INTERNAL_PAYMENTS` | `true` | Styr “Betala”-flödet. Kan också togglas i Django admin > Systeminställningar. |
| `CLAIMS_ENABLE_INTERNAL_PAYMENTS` | `true` | Styr “Betala”-flödet. Aktiveras endast via miljövariabel. |
| `CLAIMS_EMAIL_ENABLED` | `false` | Slår på e-postaviseringar. Låt vara `false` i testläge. |
| `CLAIMS_EMAIL_FROM` | `no-reply@claims.local` | Avsändare för utskick. |
| `CLAIMS_ADMIN_NOTIFICATION_EMAIL` | tom | Om satt skickas notifiering vid nytt claim (när e-post är aktiverad). |
| `EMAIL_BACKEND` | `django.core.mail.backends.console.EmailBackend` | Byt till SMTP i prod (se nedan). |
| `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD` | tom | Standard Django SMTP-inställningar. |
| `CLAIMS_MAX_RECEIPT_BYTES` | `10485760` | Maxstorlek per kvitto (i byte), default 10 MB. |
| `CLAIMS_ALLOWED_RECEIPT_EXTENSIONS` | `pdf,png,jpg,jpeg` | Tillåtna filändelser (kommaseparerade). |
| `CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES` | `application/pdf,image/png,image/jpeg` | Tillåtna MIME-typer (kommaseparerade). |
| `LANGUAGE_CODE` | `sv` | Standardspråk. |
| `LOCALE_PATHS` | `BASE_DIR/locale` | Katalog för `.po/.mo`. |
| `LOGIN_REDIRECT_URL` | `/claims/admin/` | Efter inloggning. |
| `LOGOUT_REDIRECT_URL` | `/accounts/login/` | Efter utloggning. |
| `LOGIN_REDIRECT_URL` | `claims:admin-list` | Lazy reverse till `/[lang]/claims/admin/` efter inloggning. |
| `LOGOUT_REDIRECT_URL` | `login` | Lazy reverse till `/[lang]/accounts/login/` efter utloggning. |
SMTP-exempel:
```env
@@ -76,7 +79,7 @@ EMAIL_HOST_PASSWORD=secret
### 5. Underhåll & verktyg
- **Reset claims:** `uv run python manage.py reset_claims` (bekräfta med `ja` eller använd `--noinput`). Tar bort alla claims/loggar/kvitton men lämnar användarkonton.
- **Django admin:** `/admin/` används för projekt, grupper, superusers, SystemSetting mm. Staff-användare har automatiskt åtkomst.
- **Django admin:** `/admin/` används för projekt, grupper och superusers. Staff-användare har automatiskt åtkomst.
- **Testa konfiguration:** `uv run python manage.py check`.
- **Språkreset:** Rensa cookies om språkväljaren inte byter språk (Django använder `django_language` cookien).

View File

@@ -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")

View File

@@ -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]),
),
]

View 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

View File

@@ -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
View 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",
)

View File

@@ -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"):

View File

@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
import os
from pathlib import Path
from django.urls import reverse_lazy
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -130,8 +131,8 @@ STATIC_URL = 'static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
LOGIN_REDIRECT_URL = '/claims/admin/'
LOGOUT_REDIRECT_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = reverse_lazy('claims:admin-list')
LOGOUT_REDIRECT_URL = reverse_lazy('login')
os.environ.setdefault("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true")
CLAIMS_ENABLE_INTERNAL_PAYMENTS = os.getenv("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true").lower() in {"1", "true", "yes"}
@@ -148,6 +149,18 @@ CLAIMS_EMAIL_ENABLED = os.getenv("CLAIMS_EMAIL_ENABLED", "false").lower() in {"1
CLAIMS_EMAIL_FROM = os.getenv("CLAIMS_EMAIL_FROM", "no-reply@claims.local")
CLAIMS_ADMIN_NOTIFICATION_EMAIL = os.getenv("CLAIMS_ADMIN_NOTIFICATION_EMAIL", "")
CLAIMS_MAX_RECEIPT_BYTES = int(os.getenv("CLAIMS_MAX_RECEIPT_BYTES", str(10 * 1024 * 1024)))
CLAIMS_ALLOWED_RECEIPT_EXTENSIONS = tuple(
ext.strip().lower()
for ext in os.getenv("CLAIMS_ALLOWED_RECEIPT_EXTENSIONS", "pdf,png,jpg,jpeg").split(",")
if ext.strip()
)
CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES = tuple(
ct.strip().lower()
for ct in os.getenv("CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES", "application/pdf,image/png,image/jpeg").split(",")
if ct.strip()
)
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@@ -16,17 +16,22 @@ Including another URLconf
"""
from django.conf import settings
from django.conf.urls.static import static
from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
urlpatterns = [
path('admin/', admin.site.urls),
path('claims/', include('claims.urls')),
path('accounts/', include('django.contrib.auth.urls')),
path('i18n/', include('django.conf.urls.i18n')),
path('', RedirectView.as_view(pattern_name='claims:submit', permanent=False)),
path('', RedirectView.as_view(url=f'/{settings.LANGUAGE_CODE}/', permanent=False)),
]
urlpatterns += i18n_patterns(
path('admin/', admin.site.urls),
path('accounts/', include('django.contrib.auth.urls')),
path('claims/', include('claims.urls')),
path('', RedirectView.as_view(pattern_name='claims:submit', permanent=False)),
)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)