Add support for generic file upload sponsorship benefit class
authorMagnus Hagander <magnus@hagander.net>
Wed, 3 Sep 2025 14:43:39 +0000 (16:43 +0200)
committerMagnus Hagander <magnus@hagander.net>
Wed, 3 Sep 2025 20:01:58 +0000 (22:01 +0200)
This allows the upload of a generic file, not just an image. It can be
restricted by mime types if wanted (to for example say only EPS and SVG
files), and will then validate the MIME header. It will *not* attempt
any further validation beyond the MIME header. If no MIME type is
specified on the benefit, then it will accept any uploaded file formats
and leave it entirely to the administrator to handle.

To make this work, also add support to the InlineEncodedStorage to store
metadata, and use that to store the filename so we can reuse it later.

docs/confreg/sponsors.md
postgresqleu/confsponsor/benefitclasses/__init__.py
postgresqleu/confsponsor/benefitclasses/fileupload.py [new file with mode: 0644]
postgresqleu/confsponsor/benefitclasses/imageupload.py
postgresqleu/confsponsor/migrations/0015_sponsor_scan_attendees.py
postgresqleu/confsponsor/urls.py
postgresqleu/confsponsor/views.py
postgresqleu/util/migrations/0008_storage_metadata.py [new file with mode: 0644]
postgresqleu/util/models.py
postgresqleu/util/storage.py

index ee914395c538721462b7cd4a8d50740062f6f251..b5a95f2d04c947dbd86d8d61d051eb1a9705fe22 100644 (file)
@@ -201,6 +201,12 @@ Submit session
    flagged as approved (but not put on the schedule since it has no start
    and end time yet).
 
+Upload file
+:  This benefit class allow uploading of a generic file (unlike the image one
+   which is specific to images). It can only validate the MIME type of the
+   file being uploaded and the size of the file, all further vaildation past
+   that must be done manually.
+
 ## Shipments
 
 A shipment tracking system is built into the sponsorship system. It
index 3b4705f0aa851440ffce0bbe7e452f7afec4ef06..f5baaea26676089a372202bcf92b497f4ff55597 100644 (file)
@@ -8,6 +8,7 @@ all_benefits = {
     5: {"class": "attendeelist.AttendeeList", "description": "List of attendee email addresses"},
     6: {"class": "badgescanning.BadgeScanning", "description": "Scanning of attendee badges"},
     7: {"class": "sponsorsession.SponsorSession", "description": "Submit session"},
+    8: {"class": "fileupload.FileUpload", "description": 'Upload file'},
 }
 
 
diff --git a/postgresqleu/confsponsor/benefitclasses/fileupload.py b/postgresqleu/confsponsor/benefitclasses/fileupload.py
new file mode 100644 (file)
index 0000000..3940628
--- /dev/null
@@ -0,0 +1,153 @@
+from django import forms
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.http import HttpResponse
+from django.conf import settings
+
+import base64
+import io
+import zipfile
+
+from postgresqleu.util.storage import InlineEncodedStorage
+from postgresqleu.util.forms import IntegerBooleanField
+from postgresqleu.util.widgets import StaticTextWidget
+from postgresqleu.util.validators import color_validator
+from postgresqleu.util.magic import magicdb
+from postgresqleu.confsponsor.backendforms import BackendSponsorshipLevelBenefitForm
+
+from .base import BaseBenefit, BaseBenefitForm
+
+
+class FileUploadForm(BaseBenefitForm):
+    decline = forms.BooleanField(label='Decline this benefit', required=False)
+    file = forms.FileField(label='File', required=False)
+    uploadedfile = forms.CharField(label='Uploaed', widget=forms.HiddenInput, required=False)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.fields['file'].help_text = "Upload a file of maximum size {}KB".format(self.params['maxsize'])
+        if self.params['mimetypes']:
+            acceptzipstr = ',application/zip' if self.params['acceptzip'] else ''
+            self.fields['file'].widget.attrs['accept'] = self.params['mimetypes'] + acceptzipstr
+
+    def clean(self):
+        if self.cleaned_data.get('decline', False) and self.cleaned_data.get('file', None):
+            raise ValidationError('You cannot both decline and upload a file at the same time.')
+
+        if not self.cleaned_data.get('file', None) and 'file' not in self._errors:
+            self.add_error('file', 'A file must be uploaded')
+
+        return self.cleaned_data
+
+    def clean_file(self):
+        if not self.cleaned_data.get('file', None):
+            # This check is done in the global clean as well, so we accept it here since
+            # we might have decliend it.
+            return None
+
+        filedata = self.cleaned_data['file']
+
+        if filedata.size > self.params.get('maxsize') * 1024:
+            raise ValidationError("Uploaded file is too large, maximum size is {}Kb.".format(self.params.get('maxsize')))
+
+        def _mimetype_ok(mimetype):
+            for t in self.params.get('mimetypes').split(','):
+                if mimetype.startswith(t):
+                    return True
+            return False
+
+        if self.params.get('mimetypes', None):
+            mimetype = magicdb.buffer(filedata.read(2048))
+            if self.params.get('acceptzip', False) and mimetype.startswith('application/zip'):
+                filedata.seek(0)
+                try:
+                    with zipfile.ZipFile(filedata) as zf:
+                        for fn in zf.namelist():
+                            with zf.open(fn) as ff:
+                                mimetype = magicdb.buffer(ff.read(2048))
+                                if not _mimetype_ok(mimetype):
+                                    raise ValidationError("ZIP file contains file {} which is of invalid type: {}".format(fn, mimetype))
+                except zipfile.BadZipFile:
+                    raise ValidationError("Could not parse uploaded ZIP file")
+            elif not _mimetype_ok(mimetype):
+                raise ValidationError("Invalid type of file uploaded: {}".format(mimetype))
+
+        filedata.seek(0)
+
+        return self.cleaned_data['file']
+
+
+class FileUploadBackendForm(BackendSponsorshipLevelBenefitForm):
+    maxsize = forms.IntegerField(label='Maximum size in Kb', initial=1024, validators=[MinValueValidator(10), MaxValueValidator(int(settings.DATA_UPLOAD_MAX_MEMORY_SIZE / 1024))])
+    mimetypes = forms.CharField(label='MIME types', help_text='Allow only the specified MIME types, leave empty to allow all', required=False)
+    acceptzip = forms.BooleanField(label='Accept zip', initial=True, help_text='Accept a ZIP version containing the above list of MIME types', required=False)
+
+    class_param_fields = ['maxsize', 'mimetypes', 'acceptzip', ]
+
+    def clean_mimetypes(self):
+        m = self.cleaned_data['mimetypes']
+        if m == '':
+            return m
+        parts = m.split(',')
+        for p in parts:
+            if ' ' in p:
+                raise ValidationError('Whitespace not allowed in MIME types')
+            mimeparts = p.split('/')
+            if len(mimeparts) != 2:
+                raise ValidationError('Each MIME type must be of format x/y')
+        return m
+
+    def clean(self):
+        if self.cleaned_data.get('mimetypes', '') == '' and self.cleaned_data.get('acceptzip', False):
+            self.add_error('acceptzip', 'Accept zip only makes sense when MIME type is specified')
+
+        return self.cleaned_data
+
+
+class FileUpload(BaseBenefit):
+    @classmethod
+    def get_backend_form(self):
+        return FileUploadBackendForm
+
+    def generate_form(self):
+        return FileUploadForm
+
+    def save_form(self, form, claim, request):
+        if form.cleaned_data['decline']:
+            return False
+        storage = InlineEncodedStorage('benefit_file')
+        storage.save(str(claim.id), form.cleaned_data['file'], {
+            'filename': form.cleaned_data['file'].name,
+        })
+        return True
+
+    def render_claimdata(self, claimedbenefit, isadmin):
+        if claimedbenefit.declined:
+            return 'Benefit declined.'
+
+        storage = InlineEncodedStorage('benefit_file')
+        fn = storage.get_metadata(claimedbenefit.id)['filename']
+
+        if isadmin:
+            return 'Uploaded file: {}. <a href="/events/sponsor/admin/downloadfile/{}/">Download</a>.'.format(
+                fn,
+                claimedbenefit.id,
+            )
+        else:
+            return 'Uploaded file: {}'.format(fn)
+
+    def get_claimdata(self, claimedbenefit):
+        return {
+            'filename': InlineEncodedStorage('benefit_file').get_metadata(claimedbenefit.id)['filename'],
+        }
+
+    def get_claimfile(self, claimedbenefit):
+        hashval, data, metadata = InlineEncodedStorage('benefit_file').read(claimedbenefit.id)
+        if hashval is None and data is None:
+            raise Http404()
+        resp = HttpResponse(content_type='application/octet_stream')
+        resp['Content-Disposition'] = 'attachment; filename={}'.format(metadata['filename'])
+        resp['ETag'] = '"{}"'.format(hashval)
+        resp.write(data)
+        return resp
index e951145f622fa5abfa66f1d7a1fe20a2f19204d4..344bb3682013540f7124e3fb635a3547bfd8433f 100644 (file)
@@ -164,7 +164,7 @@ class ImageUpload(BaseBenefit):
         }
 
     def get_claimfile(self, claimedbenefit):
-        hashval, data = InlineEncodedStorage('benefit_image').read(claimedbenefit.id)
+        hashval, data, metadata = InlineEncodedStorage('benefit_image').read(claimedbenefit.id)
         if hashval is None and data is None:
             raise Http404()
         resp = HttpResponse(content_type='image/png')
index f558ef77b61c750a677ed9dcf03a869ed5d74719..7c1f180b76dfbca8f54abf334efe6ad337a38bcf 100644 (file)
@@ -40,7 +40,7 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='sponsorshipbenefit',
             name='benefit_class',
-            field=models.IntegerField(blank=True, choices=[(0, 'Automatically claimed'), (1, 'Require uploaded image'), (2, 'Requires explicit claiming'), (3, 'Claim entry vouchers'), (4, 'Provide text string'), (5, 'List of attendee email addresses'), (6, 'Scanning of attendee badges'), (7, 'Submit session')], default=None, null=True),
+            field=models.IntegerField(blank=True, choices=[(0, 'Automatically claimed'), (1, 'Require uploaded image'), (2, 'Requires explicit claiming'), (3, 'Claim entry vouchers'), (4, 'Provide text string'), (5, 'List of attendee email addresses'), (6, 'Scanning of attendee badges'), (7, 'Submit session'), (8, 'Upload file')], default=None, null=True),
         ),
         migrations.AlterUniqueTogether(
             name='sponsorscanner',
index 8a507aa51ebcbb0155db88a730a85f05aeca5a83..2059f8490b2dc92d1f4aac585c752b1f89ad6c97 100644 (file)
@@ -28,6 +28,7 @@ urlpatterns = [
     re_path(r'^shipments/([a-z0-9]+)/$', views.sponsor_shipment_receiver),
     re_path(r'^shipments/([a-z0-9]+)/(\d+)/$', views.sponsor_shipment_receiver_shipment),
     re_path(r'^admin/imageview/(\d+)/$', views.sponsor_admin_imageview),
+    re_path(r'^admin/downloadfile/(\d+)/$', views.sponsor_admin_downloadfile),
     re_path(r'^admin/(\w+)/$', views.sponsor_admin_dashboard),
     re_path(r'^admin/(\w+)/(\d+)/$', views.sponsor_admin_sponsor),
     re_path(r'^admin/(\w+)/(\d+)/edit/$', backendviews.edit_sponsor),
index 5b7cdae06a0db65022a2a315f435eaada42b41d1..0da8c0bfcddf0f234ad35db0180e4b428dd38114 100644 (file)
@@ -1442,7 +1442,7 @@ def sponsor_admin_view_mail(request, confurlname, mailid):
 
 
 @login_required
-def sponsor_admin_imageview(request, benefitid):
+def _sponsor_admin_imageorfile(request, benefitid, what, finalizer):
     # Image is fetched as part of a benefit, so find the benefit
 
     benefit = get_object_or_404(SponsorClaimedBenefit, id=benefitid)
@@ -1456,7 +1456,7 @@ def sponsor_admin_imageview(request, benefitid):
 
     # If the benefit existed, we have verified the permissions, so we can now show
     # the image itself.
-    storage = InlineEncodedStorage('benefit_image')
+    storage = InlineEncodedStorage(what)
 
     # If there is an if-none-match header, it's almost certain this will be a 304 since
     # these files never change. So if it is, query just for the hash - and in the unlikely
@@ -1469,15 +1469,31 @@ def sponsor_admin_imageview(request, benefitid):
             return HttpResponseNotModified()
 
     # XXX: do we need to support non-png at some point? store info in claimdata!
-    hashval, data = storage.read(benefit.id)
+    hashval, data, metadata = storage.read(benefit.id)
     if hashval is None and data is None:
         raise Http404()
-    resp = HttpResponse(content_type='image/png')
+    resp = HttpResponse()
     resp['ETag'] = '"{}"'.format(hashval)
+    finalizer(resp, metadata)
     resp.write(data)
     return resp
 
 
+@login_required
+def sponsor_admin_imageview(request, benefitid):
+    def _finalize(response, metadata):
+        response['Content-Type'] = 'image/png'
+    return _sponsor_admin_imageorfile(request, benefitid, 'benefit_image', _finalize)
+
+
+@login_required
+def sponsor_admin_downloadfile(request, benefitid):
+    def _finalize(response, metadata):
+        response['Content-Type'] = 'application/octet-stream'
+        response['Content-Disposition'] = 'attachment; filename={}'.format(metadata['filename'])
+    return _sponsor_admin_imageorfile(request, benefitid, 'benefit_file', _finalize)
+
+
 def _claimstatus(claim):
     if claim.claimedat is None:
         return 'Unclaimed'
diff --git a/postgresqleu/util/migrations/0008_storage_metadata.py b/postgresqleu/util/migrations/0008_storage_metadata.py
new file mode 100644 (file)
index 0000000..01306b2
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.11 on 2025-09-03 14:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('util', '0007_storage_trigger_fix'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='storage',
+            name='metadata',
+            field=models.JSONField(default=dict),
+        ),
+    ]
index 8700dc5f5ea082fe73785e09b2dc91a5aa0cf1f2..64d6c4235d78a5b4ca9320f015c6e479b1894f9f 100644 (file)
@@ -7,6 +7,7 @@ class Storage(models.Model):
     storageid = models.IntegerField(null=False, blank=False)
     data = models.BinaryField(null=False, blank=False)
     hashval = models.BinaryField(null=False, blank=False)
+    metadata = models.JSONField(null=False, blank=False, default=dict)
 
     class Meta:
         unique_together = (
index ce3fdb3375accce1ea35ece05cb85ad8497423f3..243ca3c93039da30f93b866fe596386a7c9eed87 100644 (file)
@@ -2,6 +2,8 @@ from django.db import connection
 
 from postgresqleu.util.db import exec_to_scalar
 
+import json
+
 
 class InlineEncodedStorage(object):
     def __init__(self, key):
@@ -9,24 +11,34 @@ class InlineEncodedStorage(object):
 
     def read(self, name):
         curs = connection.cursor()
-        curs.execute("SELECT encode(hashval, 'hex'), data FROM util_storage WHERE key=%(key)s AND storageid=%(id)s", {
+        curs.execute("SELECT encode(hashval, 'hex'), data, metadata FROM util_storage WHERE key=%(key)s AND storageid=%(id)s", {
+            'key': self.key, 'id': name})
+        rows = curs.fetchall()
+        if len(rows) != 1:
+            return None, None, None
+        return rows[0][0], bytes(rows[0][1]), json.loads(rows[0][2])
+
+    def get_metadata(self, name):
+        curs = connection.cursor()
+        curs.execute("SELECT metadata FROM util_storage WHERE key=%(key)s AND storageid=%(id)s", {
             'key': self.key, 'id': name})
         rows = curs.fetchall()
         if len(rows) != 1:
             return None, None
-        return rows[0][0], bytes(rows[0][1])
+        return json.loads(rows[0][0])
 
-    def save(self, name, content):
+    def save(self, name, content, metadata=None):
         content.seek(0)
         curs = connection.cursor()
         params = {
             'key': self.key,
             'id': name,
             'data': content.read(),
+            'metadata': json.dumps(metadata) if metadata else None,
             }
-        curs.execute("UPDATE util_storage SET data=%(data)s WHERE key=%(key)s AND storageid=%(id)s", params)
+        curs.execute("UPDATE util_storage SET data=%(data)s, metadata=%(metadata)s WHERE key=%(key)s AND storageid=%(id)s", params)
         if curs.rowcount == 0:
-            curs.execute("INSERT INTO util_storage (key, storageid, data) VALUES (%(key)s, %(id)s, %(data)s)", params)
+            curs.execute("INSERT INTO util_storage (key, storageid, data, metadata) VALUES (%(key)s, %(id)s, %(data)s, %(metadata)s)", params)
         return name
 
     def get_tag(self, name):