diff --git a/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py b/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py new file mode 100644 index 000000000..a743d0908 --- /dev/null +++ b/apps/downloads/migrations/0015_releasefile_python_dot_org_urls.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.11 on 2026-02-25 14:13 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("downloads", "0014_releasefile_sha256_sum"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddConstraint( + model_name="releasefile", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q( + ("url__exact", ""), + ("url__startswith", "https://www.python.org/"), + ("url__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("gpg_signature_file__exact", ""), + ("gpg_signature_file__startswith", "https://www.python.org/"), + ("gpg_signature_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("sigstore_signature_file__exact", ""), + ("sigstore_signature_file__startswith", "https://www.python.org/"), + ("sigstore_signature_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("sigstore_cert_file__exact", ""), + ("sigstore_cert_file__startswith", "https://www.python.org/"), + ("sigstore_cert_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("sigstore_bundle_file__exact", ""), + ("sigstore_bundle_file__startswith", "https://www.python.org/"), + ("sigstore_bundle_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + models.Q( + ("sbom_spdx2_file__exact", ""), + ("sbom_spdx2_file__startswith", "https://www.python.org/"), + ("sbom_spdx2_file__startswith", "http://www.python.org/"), + _connector="OR", + ), + ), + name="only_python_dot_org_urls", + violation_error_message="All file URLs must begin with 'https://www.python.org/'", + ), + ), + ] diff --git a/apps/downloads/models.py b/apps/downloads/models.py index a8feb85f8..8af0a1c1d 100644 --- a/apps/downloads/models.py +++ b/apps/downloads/models.py @@ -357,6 +357,17 @@ def update_boxes_on_release_file_delete(sender, instance, **kwargs): _update_boxes_for_release_file(instance) +def condition_url_is_blank_or_python_dot_org(column: str): + """Conditions for a URLField column to force 'http[s]://python.org'.""" + return ( + models.Q(**{f"{column}__exact": ""}) + | models.Q(**{f"{column}__startswith": "https://www.python.org/"}) + # Older releases allowed 'http://'. 'https://' is required at + # the API level, so shouldn't show up in newer releases. + | models.Q(**{f"{column}__startswith": "http://www.python.org/"}) + ) + + class ReleaseFile(ContentManageable, NameSlugModel): """Individual files in a release. @@ -406,4 +417,16 @@ class Meta: condition=models.Q(download_button=True), name="only_one_download_per_os_per_release", ), + models.CheckConstraint( + condition=( + condition_url_is_blank_or_python_dot_org("url") + & condition_url_is_blank_or_python_dot_org("gpg_signature_file") + & condition_url_is_blank_or_python_dot_org("sigstore_signature_file") + & condition_url_is_blank_or_python_dot_org("sigstore_cert_file") + & condition_url_is_blank_or_python_dot_org("sigstore_bundle_file") + & condition_url_is_blank_or_python_dot_org("sbom_spdx2_file") + ), + name="only_python_dot_org_urls", + violation_error_message="All file URLs must begin with 'https://www.python.org/'", + ), ] diff --git a/apps/downloads/tests/base.py b/apps/downloads/tests/base.py index 18005aaa5..fb38d5d92 100644 --- a/apps/downloads/tests/base.py +++ b/apps/downloads/tests/base.py @@ -36,14 +36,14 @@ def setUp(self): release=self.release_275, name="Windows x86 MSI Installer (2.7.5)", description="Windows binary -- does not include source", - url="ftp/python/2.7.5/python-2.7.5.msi", + url="https://www.python.org/ftp/python/2.7.5/python-2.7.5.msi", ) self.release_275_windows_64bit = ReleaseFile.objects.create( os=self.windows, release=self.release_275, name="Windows X86-64 MSI Installer (2.7.5)", description="Windows AMD64 / Intel 64 / X86-64 binary -- does not include source", - url="ftp/python/2.7.5/python-2.7.5.amd64.msi", + url="https://www.python.org/ftp/python/2.7.5/python-2.7.5.amd64.msi", ) self.release_275_osx = ReleaseFile.objects.create( @@ -51,7 +51,7 @@ def setUp(self): release=self.release_275, name="Mac OSX 64-bit/32-bit", description="Mac OS X 10.6 and later", - url="ftp/python/2.7.5/python-2.7.5-macosx10.6.dmg", + url="https://www.python.org/ftp/python/2.7.5/python-2.7.5-macosx10.6.dmg", ) self.release_275_linux = ReleaseFile.objects.create( @@ -60,7 +60,7 @@ def setUp(self): release=self.release_275, is_source=True, description="Gzipped source", - url="ftp/python/2.7.5/Python-2.7.5.tgz", + url="https://www.python.org/ftp/python/2.7.5/Python-2.7.5.tgz", filesize=12345678, ) @@ -77,7 +77,7 @@ def setUp(self): release=self.draft_release, is_source=True, description="Gzipped source", - url="ftp/python/9.7.2/Python-9.7.2.tgz", + url="https://www.python.org/ftp/python/9.7.2/Python-9.7.2.tgz", ) self.hidden_release = Release.objects.create( diff --git a/apps/downloads/tests/test_models.py b/apps/downloads/tests/test_models.py index a409b51f0..d1d4c97b3 100644 --- a/apps/downloads/tests/test_models.py +++ b/apps/downloads/tests/test_models.py @@ -1,7 +1,10 @@ import datetime as dt from unittest.mock import patch -from apps.downloads.models import Release, ReleaseFile +from django.db import IntegrityError, transaction +from django.db.models import URLField + +from apps.downloads.models import OS, Release, ReleaseFile from apps.downloads.tests.base import BaseDownloadTests @@ -160,7 +163,7 @@ def test_update_supernav(self): release=self.python_3, slug=slug, name="Python 3.10", - url=f"/ftp/python/{slug}.zip", + url=f"https://www.python.org/ftp/python/{slug}.zip", download_button=True, ) @@ -179,7 +182,7 @@ def test_update_supernav(self): os=self.windows, release=release, name="MSIX", - url="/ftp/python/pymanager/pymanager-25.0.msix", + url="https://www.python.org/ftp/python/pymanager/pymanager-25.0.msix", download_button=True, ) @@ -199,7 +202,7 @@ def test_update_supernav_skips_os_without_files(self): """ # Arrange from apps.boxes.models import Box - from apps.downloads.models import OS, update_supernav + from apps.downloads.models import update_supernav # Create an OS without any release files OS.objects.create(name="Android", slug="android") @@ -215,7 +218,7 @@ def test_update_supernav_skips_os_without_files(self): release=self.python_3, slug=slug, name="Python 3.10", - url=f"/ftp/python/{slug}.zip", + url=f"https://www.python.org/ftp/python/{slug}.zip", download_button=True, ) @@ -247,7 +250,7 @@ def test_release_file_save_triggers_box_updates(self, mock_home, mock_sources, m os=self.windows, release=self.python_3, name="Windows installer", - url="/ftp/python/3.10.19/python-3.10.19.exe", + url="https://www.python.org/ftp/python/3.10.19/python-3.10.19.exe", download_button=True, ) @@ -268,7 +271,7 @@ def test_release_file_save_skips_unpublished_release(self, mock_home, mock_sourc os=self.windows, release=self.draft_release, name="Windows installer draft", - url="/ftp/python/9.7.2/python-9.7.2.exe", + url="https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe", ) mock_supernav.assert_not_called() @@ -289,3 +292,22 @@ def test_release_file_delete_triggers_box_updates(self, mock_home, mock_sources, mock_supernav.assert_called() mock_sources.assert_called() mock_home.assert_called() + + def test_release_file_urls_not_python_dot_org(self): + for field in ReleaseFile._meta.get_fields(): # noqa: SLF001 + if not isinstance(field, URLField): + continue + with self.subTest(field.name), transaction.atomic(): + kwargs = { + "url": "https://www.python.org/ftp/python/9.7.2/python-9.7.2.exe", + # field.name may be 'url', but will replace the default value. + field.name: "https://notpython.com/python-9.7.2.txt", + } + + with self.assertRaises(IntegrityError): + ReleaseFile.objects.create( + os=self.windows, + release=self.draft_release, + name="Windows installer draft", + **kwargs, + )