diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b189a5aa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "files.associations": { + "**/*.html": "html", + "**/templates/*/*.html": "django-html", + "**/templates/*": "django-txt", + "**/requirements{/**,*}.{txt,in}": "pip-requirements" + }, + + "emmet.includeLanguages": { + "django-html": "html" + }, + "beautify.language": { + "html": [ + "htm", + "html", + "django-html" + ] + } +} \ No newline at end of file diff --git a/dockerize/.env.template b/dockerize/.env.template index 43f8d6bb..87086092 100644 --- a/dockerize/.env.template +++ b/dockerize/.env.template @@ -31,4 +31,7 @@ DEFAULT_PLUGINS_SITE='https://plugins.qgis.org/' QGISPLUGINS_ENV=debug # Ldap -ENABLE_LDAP=True \ No newline at end of file +ENABLE_LDAP=False + +# Download limit per minute +DOWNLOAD_RATE_LIMIT=10 \ No newline at end of file diff --git a/dockerize/docker-compose.yml b/dockerize/docker-compose.yml index 225f9784..461b9c48 100644 --- a/dockerize/docker-compose.yml +++ b/dockerize/docker-compose.yml @@ -53,6 +53,7 @@ services: - EMAIL_HOST_USER=${EMAIL_HOST_USER:-automation} - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} - DEFAULT_PLUGINS_SITE=${DEFAULT_PLUGINS_SITE:-https://plugins.qgis.org/} + - DOWNLOAD_RATE_LIMIT=${DOWNLOAD_RATE_LIMIT:-10} volumes: - ../qgis-app:/home/web/django_project - ./docker/uwsgi.conf:/uwsgi.conf diff --git a/dockerize/sites-enabled/prod-ssl.conf b/dockerize/sites-enabled/prod-ssl.conf index 589715f1..cd79e807 100644 --- a/dockerize/sites-enabled/prod-ssl.conf +++ b/dockerize/sites-enabled/prod-ssl.conf @@ -4,6 +4,9 @@ upstream uwsgi { server uwsgi:8080; } +# Define the rate limit zone +limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s; + server { # OTF gzip compression gzip on; @@ -66,6 +69,11 @@ server { } # Finally, send all non-media requests to the Django server. location / { + + # Apply rate limit + limit_req zone=one burst=20 nodelay; + limit_req_status 429; + uwsgi_pass uwsgi; # the uwsgi_params file you installed needs to be passed with each # request. @@ -90,6 +98,13 @@ server { } } + # New rule to allow download from QGIS user agent only + location ~* ^/plugins/[^/]+/version/[^/]+/download/$ { + if ($http_user_agent !~* "Mozilla/5.0 QGIS") { + return 403 "Forbidden: Please use QGIS to download .zip files."; + } + } + location /metabase/ { # set to webroot path proxy_pass http://metabase:3000/; @@ -184,6 +199,10 @@ server { } # Finally, send all non-media requests to the Django server. location / { + # Apply rate limit + limit_req zone=one burst=20 nodelay; + limit_req_status 429; + uwsgi_pass uwsgi; # the uwsgi_params file you installed needs to be passed with each # request. @@ -209,6 +228,12 @@ server { } + # New rule to the download from QGIS user agent only + location ~* ^/plugins/[^/]+/version/[^/]+/download/$ { + if ($http_user_agent !~* "Mozilla/5.0 QGIS") { + return 403 "Forbidden: Please use QGIS to download .zip files."; + } + } location /metabase/ { # set to webroot path diff --git a/dockerize/sites-enabled/prod.conf b/dockerize/sites-enabled/prod.conf index 086d6903..87eb4350 100644 --- a/dockerize/sites-enabled/prod.conf +++ b/dockerize/sites-enabled/prod.conf @@ -4,6 +4,9 @@ upstream uwsgi { server uwsgi:8080; } +# Define the rate limit zone +limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s; + server { # OTF gzip compression gzip on; @@ -63,6 +66,11 @@ server { } # Finally, send all non-media requests to the Django server. location / { + + # Apply rate limit + limit_req zone=one burst=20 nodelay; + limit_req_status 429; + uwsgi_pass uwsgi; # the uwsgi_params file you installed needs to be passed with each # request. @@ -88,6 +96,12 @@ server { } + # New rule to the download from QGIS user agent only + location ~* ^/plugins/[^/]+/version/[^/]+/download/$ { + if ($http_user_agent !~* "Mozilla/5.0 QGIS") { + return 403 "Forbidden: Please use QGIS to download .zip files."; + } + } location /metabase/ { # set to webroot path diff --git a/qgis-app/plugins/forms.py b/qgis-app/plugins/forms.py index 9f98f03f..a2b882b1 100644 --- a/qgis-app/plugins/forms.py +++ b/qgis-app/plugins/forms.py @@ -269,4 +269,28 @@ class Meta: model = PluginOutstandingToken fields = ( "description", - ) \ No newline at end of file + ) + +class VersionDownloadForm(forms.Form): + """Download confirmation for a plugin version""" + required_css_class = "required" + plugin_name = forms.CharField( + label=_("Confirm plugin name"), + required=True, + help_text=_( + "Please insert the plugin name shown above to proceed with the download." + ), + widget=forms.TextInput, + ) + def __init__(self, *args, original_name=None, **kwargs): + super(VersionDownloadForm, self).__init__(*args, **kwargs) + self.original_name = original_name + + def clean(self): + """ + Check if plugin name match + """ + super().clean() + if self.cleaned_data.get("plugin_name") != self.original_name: + raise ValidationError(_("Plugin name mismatch: Please ensure the plugin name matches the one displayed above in order to proceed with the download.")) + return super(VersionDownloadForm, self).clean() \ No newline at end of file diff --git a/qgis-app/plugins/models.py b/qgis-app/plugins/models.py index 72e8cc5a..b229c9b0 100644 --- a/qgis-app/plugins/models.py +++ b/qgis-app/plugins/models.py @@ -844,7 +844,7 @@ def get_absolute_url(self): def get_download_url(self): return reverse( - "version_download", + "version_get", args=( self.plugin.package_name, self.version, diff --git a/qgis-app/plugins/templates/plugins/plugin_download.html b/qgis-app/plugins/templates/plugins/plugin_download.html new file mode 100644 index 00000000..5c2ce708 --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_download.html @@ -0,0 +1,114 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% load local_timezone %} +{% block content %} + +
+

{% trans "Plugin: " %}

+
+ {% trans "Copy to clipboard" %} + + {{plugin_name}} + + + +
+
+ +{% if form.errors %} +
+ +

{% trans "The form contains errors and cannot be submitted, please check the fields highlighted in red." %}

+
+{% endif %} +{% if form.non_field_errors %} +
+ + {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} +
+{% endif %} +
{% csrf_token %} + {% include "plugins/form_snippet.html" %} +
+ +
+
+{% endblock %} + + +{% block extrajs %} +{{ block.super }} + +{% endblock %} +{% block extracss %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/templates/plugins/plugin_download_limit_exceed.html b/qgis-app/plugins/templates/plugins/plugin_download_limit_exceed.html new file mode 100644 index 00000000..e98a3ac1 --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_download_limit_exceed.html @@ -0,0 +1,17 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} +{% block content %} +
{% trans "Download rate limit exceeded. Try again later." %}
+{% endblock %} + + +{% block extracss %} +{{ block.super }} + + + +{% endblock %} \ No newline at end of file diff --git a/qgis-app/plugins/templates/plugins/plugin_download_success.html b/qgis-app/plugins/templates/plugins/plugin_download_success.html new file mode 100644 index 00000000..dbf50a2b --- /dev/null +++ b/qgis-app/plugins/templates/plugins/plugin_download_success.html @@ -0,0 +1,42 @@ +{% extends 'plugins/plugin_base.html' %}{% load i18n %} {% load local_timezone +%} {% block content %} +
+ The download of the plugin {{plugin_name}} will start shortly... +
+ +{% endblock %} {% block extrajs %} {{ block.super }} + +{% endblock %} {% block extracss %} {{ block.super }} + +{% endblock %} diff --git a/qgis-app/plugins/templates/plugins/plugin_list.html b/qgis-app/plugins/templates/plugins/plugin_list.html index 788053d6..a322ac4a 100644 --- a/qgis-app/plugins/templates/plugins/plugin_list.html +++ b/qgis-app/plugins/templates/plugins/plugin_list.html @@ -104,8 +104,8 @@

{% if title %}{{title}}{% else %}{% trans "All plugins" %}{% endif %}

{{ object.latest_version_date|local_timezone:"SHORT_NATURAL_DAY" }} {{ object.created_on|local_timezone:"SHORT" }}
({{ object.rating_votes }})
- {% if object.stable %}{{ object.stable.version }}{% else %}—{% endif %} - {% if object.experimental %}{{ object.experimental.version }}{% else %}—{% endif %} + {% if object.stable %}{{ object.stable.version }}{% else %}—{% endif %} + {% if object.experimental %}{{ object.experimental.version }}{% else %}—{% endif %} {% if user.is_authenticated %}{% if user in object.editors or user.is_staff %} {% else %}{% endif %}{% endif %} diff --git a/qgis-app/plugins/templates/plugins/version_detail.html b/qgis-app/plugins/templates/plugins/version_detail.html index b9cd4874..847bf572 100644 --- a/qgis-app/plugins/templates/plugins/version_detail.html +++ b/qgis-app/plugins/templates/plugins/version_detail.html @@ -2,7 +2,7 @@ {% load local_timezone %} {% block content %}

{% trans "Version" %}: {{ version }}

-
{% trans "Download" %}
+
{% trans "Download" %}
{% if not version.created_by.is_active and not version.is_from_token %}
diff --git a/qgis-app/plugins/tests/test_download.py b/qgis-app/plugins/tests/test_download.py index f18f0874..d8ab96be 100644 --- a/qgis-app/plugins/tests/test_download.py +++ b/qgis-app/plugins/tests/test_download.py @@ -6,6 +6,11 @@ from plugins.models import Plugin, PluginVersion, PluginVersionDownload from plugins.views import version_download +from django.conf import settings +from django.core.cache import cache +from django.urls import reverse +from unittest.mock import patch +from plugins.forms import VersionDownloadForm class TestVersionDownloadView(TestCase): def setUp(self): @@ -50,3 +55,79 @@ def test_version_download(self): self.assertEqual(self.version.downloads, 1) self.assertEqual(self.plugin.downloads, 1) self.assertEqual(download_record.download_count, 1) + + +class VersionGetViewTest(TestCase): + fixtures = [ + "fixtures/styles.json", + "fixtures/auth.json", + "fixtures/simplemenu.json", + ] + def setUp(self): + self.factory = RequestFactory() + + self.user = User.objects.create_user( + username='testuser', + password='12345' + ) + + self.plugin = Plugin.objects.create( + package_name="test-package", + created_by=self.user, + name="Test Package" + ) + + self.version = PluginVersion.objects.create( + plugin=self.plugin, + version="1.0.0", + downloads=0, + created_by=self.user, + package=SimpleUploadedFile("test.zip", b"file_content"), + min_qg_version='3.1.1', + max_qg_version='3.3.0' + ) + self.url = reverse( + 'version_get', + kwargs={ + 'package_name': self.plugin.package_name, + 'version': self.version.version + } + ) + + self.ip = '127.0.0.1' + self.cache_key = f'download_limit_{self.ip}' + settings.DOWNLOAD_RATE_LIMIT = 5 # Set a rate limit for testing + + def tearDown(self): + # Clear cache after each test + cache.clear() + + def test_rate_limit_not_exceeded(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'plugins/plugin_download.html') + + def test_rate_limit_exceeded(self): + # Simulate exceeding the rate limit + cache.set(self.cache_key, settings.DOWNLOAD_RATE_LIMIT, timeout=60) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 429) + self.assertTemplateUsed(response, 'plugins/plugin_download_limit_exceed.html') + + def test_successful_download_post(self): + # Mocking _get_file_content function + with patch('plugins.views._get_file_content') as mock_get_file_content: + mock_get_file_content.return_value = ('file_content', 'file.zip') + response = self.client.post(self.url, {'plugin_name': 'Test Package'}) # Adjust form data as needed + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'plugins/plugin_download_success.html') + self.assertIn('file_content', response.context) + self.assertIn('file_name', response.context) + self.assertIn('plugin_name', response.context) + + def test_form_display_on_get_request(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'plugins/plugin_download.html') + self.assertIsInstance(response.context['form'], VersionDownloadForm) + self.assertEqual(response.context['plugin_name'], self.plugin.name) \ No newline at end of file diff --git a/qgis-app/plugins/tests/test_plugin_version_feedback.py b/qgis-app/plugins/tests/test_plugin_version_feedback.py index 8f7c7702..bdf17a26 100644 --- a/qgis-app/plugins/tests/test_plugin_version_feedback.py +++ b/qgis-app/plugins/tests/test_plugin_version_feedback.py @@ -10,6 +10,7 @@ from plugins.models import Plugin, PluginVersion, PluginVersionFeedback from plugins.views import version_feedback_notify from django.conf import settings +from django.core.cache import cache class SetupMixin: fixtures = ["fixtures/auth.json", "fixtures/simplemenu.json"] @@ -150,6 +151,9 @@ def test_staff_should_see_plugin_feedback_received(self): self.assertContains(response, "test plugin 1") self.assertNotContains(response, "test plugin 2") + # Clear django cache before sending another request + cache.clear() + # add feedback for plugin 2 PluginVersionFeedback.objects.create( version=self.version_2, @@ -170,6 +174,10 @@ def test_approved_plugin_should_not_show_in_feedback_received_list(self): list(response.context['object_list']), [self.plugin_1] ) + + # Clear django cache before sending another request + cache.clear() + self.version_1.approved = True self.version_1.save() response = self.client.get(self.url) @@ -186,6 +194,10 @@ def setUp(self): super().setUp() self.url = reverse("feedback_pending_plugins") + def tearDown(self): + # Clear cache after each test + cache.clear() + def test_non_staff_should_not_see_plugin_feedback_pending_list(self): response = self.client.get(self.url) self.assertEqual(response.status_code, 404) @@ -208,6 +220,9 @@ def test_staff_should_see_plugin_feedback_pending_list(self): self.assertContains(response, "test plugin 2") self.assertNotContains(response, "test plugin 1") + # Clear django cache before sending another request + cache.clear() + # add feedback for plugin 2 PluginVersionFeedback.objects.create( version=self.version_2, diff --git a/qgis-app/plugins/urls.py b/qgis-app/plugins/urls.py index 3d1c5da3..e7ef4f82 100644 --- a/qgis-app/plugins/urls.py +++ b/qgis-app/plugins/urls.py @@ -288,6 +288,12 @@ {}, name="version_download", ), + url( + r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/get/$", + version_get, + {}, + name="version_get", + ), url( r"^(?P[A-Za-z][A-Za-z0-9-_]+)/version/(?P[^\/]+)/approve/$", version_approve, diff --git a/qgis-app/plugins/views.py b/qgis-app/plugins/views.py index 20dfca1c..4242eabb 100644 --- a/qgis-app/plugins/views.py +++ b/qgis-app/plugins/views.py @@ -16,7 +16,7 @@ from django.db.models.expressions import RawSQL from django.db.models.functions import Lower from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, render, redirect from django.urls import reverse from django.utils.timezone import now from django.utils.decorators import method_decorator @@ -27,6 +27,7 @@ from django.views.decorators.http import require_POST from django.views.generic.detail import DetailView from django.db import transaction +from django.core.cache import cache # from sortable_listview import SortableListView from django.views.generic.list import ListView @@ -1506,10 +1507,64 @@ def version_feedback_delete(request, package_name, version, feedback): is_update_succeed: bool = True return JsonResponse({"success": is_update_succeed}) +def version_get(request, package_name, version): + """ + Download a plugin zip from the browser + """ + + ip = request.META['REMOTE_ADDR'] + key = f"download_limit_{ip}" + downloads = cache.get(key, 0) + + if downloads >= settings.DOWNLOAD_RATE_LIMIT: + response = render( + request, + "plugins/plugin_download_limit_exceed.html", + {} + ) + response.status_code = 429 + return response + + plugin = get_object_or_404(Plugin, package_name=package_name) + if request.method == "POST": + form = VersionDownloadForm(request.POST, original_name=plugin.name) + if form.is_valid(): + file_content, file_name = _get_file_content(package_name, version) + cache.set(key, downloads + 1, timeout=60) # 60 seconds timeout for limited downloads per minute + return render( + request, + "plugins/plugin_download_success.html", + { + "file_content": file_content, + "file_name": file_name, + "plugin_name": plugin.name + } + ) + else: + form = VersionDownloadForm(original_name=plugin.name) + + return render( + request, + "plugins/plugin_download.html", + { + "form": form, + "plugin_name": plugin.name + } + ) + def version_download(request, package_name, version): """ - Update download counter(s) + Download a plugin zip from QGIS + """ + file_content, file_name = _get_file_content(package_name, version) + response = HttpResponse(file_content, content_type="application/zip") + response["Content-Disposition"] = f"attachment; filename={file_name}.zip" + return response + +def _get_file_content(package_name, version): + """ + Update download counter(s) and return the file content """ plugin = get_object_or_404(Plugin, package_name=package_name) version = get_object_or_404(PluginVersion, plugin=plugin, version=version) @@ -1534,12 +1589,7 @@ def version_download(request, package_name, version): version.package.file.file.close() zipfile = open(version.package.file.name, "rb") file_content = zipfile.read() - response = HttpResponse(file_content, content_type="application/zip") - response["Content-Disposition"] = "attachment; filename=%s-%s.zip" % ( - version.plugin.package_name, - version.version, - ) - return response + return [file_content, f"{version.plugin.package_name}-{version.version}"] def version_detail(request, package_name, version): diff --git a/qgis-app/settings.py b/qgis-app/settings.py index 23d50828..a440b0a7 100644 --- a/qgis-app/settings.py +++ b/qgis-app/settings.py @@ -203,8 +203,8 @@ # See http://docs.djangoproject.com/en/dev/topics/cache/ CACHES = { "default": { - #'BACKEND': 'django.core.cache.backends.db.DatabaseCache', - "BACKEND": "django.core.cache.backends.dummy.DummyCache", + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + # "BACKEND": "django.core.cache.backends.dummy.DummyCache", "LOCATION": "cache_table", } } diff --git a/qgis-app/settings_docker.py b/qgis-app/settings_docker.py index c0b7be1e..995d6a62 100644 --- a/qgis-app/settings_docker.py +++ b/qgis-app/settings_docker.py @@ -159,4 +159,7 @@ MATOMO_URL="//matomo.qgis.org/" # Default primary key type -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' \ No newline at end of file +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + +# Download limit per minute +DOWNLOAD_RATE_LIMIT = int(os.environ.get("DOWNLOAD_RATE_LIMIT", 10)) \ No newline at end of file