diff --git a/src/shared/listeners/notify_users.py b/src/shared/listeners/notify_users.py index 01a5c216..200e3d98 100644 --- a/src/shared/listeners/notify_users.py +++ b/src/shared/listeners/notify_users.py @@ -14,48 +14,95 @@ def create_package_subscription_notifications( suggestion: CVEDerivationClusterProposal, ) -> None: """ - Create notifications for users subscribed to packages affected by the suggestion. + Create notifications for users subscribed to packages affected by the suggestion + and for maintainers of those packages (if they have auto-subscribe enabled). """ - # Extract all affected package names from the suggestion + + # Query package attributes directly from the suggestion's derivations affected_packages = list( suggestion.derivations.values_list("attribute", flat=True).distinct() ) + cve_id = suggestion.cve.cve_id if not affected_packages: logger.debug(f"No packages found for suggestion {suggestion.pk}") return # Find users subscribed to ANY of these packages - subscribed_users = User.objects.filter( + subscribed_users_qs = User.objects.filter( profile__package_subscriptions__overlap=affected_packages ).select_related("profile") + subscribed_users_set = set(subscribed_users_qs) - if not subscribed_users.exists(): - logger.debug(f"No subscribed users found for packages: {affected_packages}") - return + # Find maintainers of affected packages with auto-subscribe enabled + maintainer_users_qs = ( + User.objects.filter( + username__in=suggestion.derivations.filter( + metadata__maintainers__isnull=False + ).values_list("metadata__maintainers__github", flat=True), + profile__auto_subscribe_to_maintained_packages=True, + ) + .select_related("profile") + .distinct() + ) + + maintainer_users = set(maintainer_users_qs) + + logger.debug( + f"Found {len(maintainer_users)} maintainers with auto-subscribe enabled for suggestion {suggestion.pk}" + ) + + # Combine both sets of users, avoiding duplicates + all_users_to_notify = subscribed_users_set | maintainer_users + + logger.debug(f"About to notify users about packages: {affected_packages}") + logger.debug(f"Users to notify: {all_users_to_notify}") logger.info( - f"Creating notifications for {subscribed_users.count()} users for CVE {suggestion.cve.cve_id}" + f"Creating notifications for {len(all_users_to_notify)} users for CVE {cve_id} " + f"({len(subscribed_users_set)} subscribed, {len(maintainer_users)} maintainers)" ) - for user in subscribed_users: - # Find which of their subscribed packages are actually affected - user_affected_packages = [ - pkg - for pkg in user.profile.package_subscriptions - if pkg in affected_packages - ] + for user in all_users_to_notify: + # Determine notification reason and affected packages for this user + user_affected_packages = [] + notification_reason = [] + + # Check if user is subscribed to any affected packages + if user in subscribed_users_set: + user_subscribed_packages = [ + pkg + for pkg in user.profile.package_subscriptions + if pkg in affected_packages + ] + user_affected_packages.extend(user_subscribed_packages) + if user_subscribed_packages: + notification_reason.append("subscribed to") + + # Check if user is a maintainer with auto-subscribe enabled + if user in maintainer_users: + # For maintainers, all affected packages are relevant + maintainer_packages = [ + pkg for pkg in affected_packages if pkg not in user_affected_packages + ] + user_affected_packages.extend(maintainer_packages) + if maintainer_packages or (user not in subscribed_users_set): + notification_reason.append("maintainer of") + + if not user_affected_packages: + continue # Create notification try: + reason_text = " and ".join(notification_reason) Notification.objects.create_for_user( user=user, title=f"New security suggestion affects: {', '.join(user_affected_packages)}", - message=f"CVE {suggestion.cve.cve_id} may affect packages you're subscribed to. " + message=f"CVE {cve_id} may affect packages you're {reason_text}. " f"Affected packages: {', '.join(user_affected_packages)}. ", ) logger.debug( - f"Created notification for user {user.username} for packages: {user_affected_packages}" + f"Created notification for user {user.username} ({reason_text}) for packages: {user_affected_packages}" ) except Exception as e: logger.error(f"Failed to create notification for user {user.username}: {e}") diff --git a/src/webview/migrations/0005_profile_auto_subscribe_to_maintained_packages_and_more.py b/src/webview/migrations/0005_profile_auto_subscribe_to_maintained_packages_and_more.py new file mode 100644 index 00000000..558b0cc5 --- /dev/null +++ b/src/webview/migrations/0005_profile_auto_subscribe_to_maintained_packages_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.24 on 2025-11-05 14:27 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webview', '0004_remove_profile_subscriptions_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='auto_subscribe_to_maintained_packages', + field=models.BooleanField(default=True, help_text='Automatically subscribe to notifications for packages this user maintains'), + ), + migrations.AlterField( + model_name='profile', + name='package_subscriptions', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, help_text="Package attribute names this user has subscribed to manually (e.g., 'firefox', 'chromium')", size=None), + ), + ] diff --git a/src/webview/models.py b/src/webview/models.py index 7f5a2f7d..5c69d416 100644 --- a/src/webview/models.py +++ b/src/webview/models.py @@ -20,7 +20,11 @@ class Profile(models.Model): models.CharField(max_length=255), default=list, blank=True, - help_text="Package attribute names this user has subscribed to (e.g., 'firefox', 'chromium')", + help_text="Package attribute names this user has subscribed to manually (e.g., 'firefox', 'chromium')", + ) + auto_subscribe_to_maintained_packages = models.BooleanField( + default=True, + help_text="Automatically subscribe to notifications for packages this user maintains", ) def recalculate_unread_notifications_count(self) -> None: diff --git a/src/webview/static/subscriptions.css b/src/webview/static/subscriptions.css index f429d33c..3b28fef2 100644 --- a/src/webview/static/subscriptions.css +++ b/src/webview/static/subscriptions.css @@ -128,7 +128,9 @@ font-weight: bold; } -.package-subscription .subscription-panel { +/* Togglers */ + +.toggler { display: flex; justify-content: space-between; align-items: center; @@ -137,35 +139,35 @@ border-radius: 0.2em; } -.package-subscription .subscription-panel.subscribed { +.toggler-on { background: var(--subscription-subscribe-background-color); } -.package-subscription .subscription-panel.unsubscribed { +.toggler-off { background: #eee; } -.package-subscription .subscription-panel button { +.toggler button { border: none; border-radius: 0.2em; padding: 0.4em 1em; cursor: pointer; } -.package-subscription .subscribed button { +.toggler-on button { color: var(--subscription-unsubscribe-color); background: white; border: solid 1px var(--subscription-unsubscribe-color); font-weight: bold; } -.package-subscription .unsubscribed button { +.toggler-off button { background: var(--subscription-subscribe-color); color: white; font-weight: bold; } -.package-subscription .subscription-status { +.toggler-status { display: flex; justify-content: space-between; align-items: center; @@ -173,14 +175,14 @@ font-weight: bold; } -.package-subscription .subscription-status .status-icon { +.toggler-icon { font-size: 3em; } -.package-subscription .subscribed .subscription-status { +.toggler-on .toggler-status { color: var(--subscription-subscribe-color); } -.package-subscription .unsubscribed .subscription-status { +.toggler-off .toggler-status { color: #777; } diff --git a/src/webview/subscriptions/urls.py b/src/webview/subscriptions/urls.py index 3074bb17..ebbf5364 100644 --- a/src/webview/subscriptions/urls.py +++ b/src/webview/subscriptions/urls.py @@ -5,6 +5,7 @@ PackageSubscriptionView, RemoveSubscriptionView, SubscriptionCenterView, + ToggleAutoSubscribeView, ) app_name = "subscriptions" @@ -13,6 +14,11 @@ path("", SubscriptionCenterView.as_view(), name="center"), path("add/", AddSubscriptionView.as_view(), name="add"), path("remove/", RemoveSubscriptionView.as_view(), name="remove"), + path( + "toggle-auto-subscribe/", + ToggleAutoSubscribeView.as_view(), + name="toggle_auto_subscribe", + ), path( "package//", PackageSubscriptionView.as_view(), name="package" ), diff --git a/src/webview/subscriptions/views.py b/src/webview/subscriptions/views.py index 7b1527c0..81860909 100644 --- a/src/webview/subscriptions/views.py +++ b/src/webview/subscriptions/views.py @@ -141,6 +141,49 @@ def _handle_error(self, request: HttpRequest, error_message: str) -> HttpRespons return redirect(reverse("webview:subscriptions:center")) +class ToggleAutoSubscribeView(LoginRequiredMixin, TemplateView): + """Toggle auto-subscription to maintained packages.""" + + template_name = "subscriptions/components/auto_subscribe.html" + + def post(self, request: HttpRequest) -> HttpResponse: + """Toggle auto-subscription setting.""" + action = request.POST.get("action", "") + + if action not in ["enable", "disable"]: + return self._handle_error(request, "Invalid action.") + + profile = request.user.profile + + profile.auto_subscribe_to_maintained_packages = action == "enable" + + profile.save(update_fields=["auto_subscribe_to_maintained_packages"]) + + # Handle HTMX vs standard request + if request.headers.get("HX-Request"): + return self.render_to_response( + { + "auto_subscribe_enabled": profile.auto_subscribe_to_maintained_packages, + } + ) + else: + return redirect(reverse("webview:subscriptions:center")) + + def _handle_error(self, request: HttpRequest, error_message: str) -> HttpResponse: + """Handle error responses for both HTMX and standard requests.""" + if request.headers.get("HX-Request"): + return self.render_to_response( + { + "auto_subscribe_enabled": request.user.profile.auto_subscribe_to_maintained_packages, + "error_message": error_message, + } + ) + else: + # Without javascript, we use Django messages for the errors + messages.error(request, error_message) + return redirect(reverse("webview:subscriptions:center")) + + class PackageSubscriptionView(LoginRequiredMixin, TemplateView): """Display a package subscription page for a specific package.""" @@ -164,6 +207,11 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: else: context["is_subscribed"] = False + # Check if user maintains this package and has automatic subscription enabled + context["auto_subscribe_enabled"] = ( + self.request.user.profile.auto_subscribe_to_maintained_packages + ) + return context def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: diff --git a/src/webview/templates/subscriptions/components/auto_subscribe.html b/src/webview/templates/subscriptions/components/auto_subscribe.html new file mode 100644 index 00000000..a38ce98e --- /dev/null +++ b/src/webview/templates/subscriptions/components/auto_subscribe.html @@ -0,0 +1,22 @@ +{% load viewutils %} + +
+
+
{% if auto_subscribe_enabled %}✓{% else %}✕{% endif %}
+
{% if auto_subscribe_enabled %}You will automatically receive notifications about packages you maintain.{% else %}By default, you won't receive notifications for the packages you maintain.{% endif %}
+
+
+ {% csrf_token %} + {% if auto_subscribe_enabled %} + + + {% else %} + + + {% endif %} +
+
diff --git a/src/webview/templates/subscriptions/components/packages.html b/src/webview/templates/subscriptions/components/packages.html index 54ed915d..fbfdc805 100644 --- a/src/webview/templates/subscriptions/components/packages.html +++ b/src/webview/templates/subscriptions/components/packages.html @@ -1,8 +1,6 @@ {% load viewutils %}
-

Packages

- {% if error_message %}
{{ error_message }}
diff --git a/src/webview/templates/subscriptions/package_subscription.html b/src/webview/templates/subscriptions/package_subscription.html index d0b20585..eba17123 100644 --- a/src/webview/templates/subscriptions/package_subscription.html +++ b/src/webview/templates/subscriptions/package_subscription.html @@ -28,10 +28,10 @@

Package Not Found

{{ package_name }}

Subscribe to receive notifications about security alerts suggestions that may affect this package.

-
-
-
{% if is_subscribed %}✓{% else %}✕{% endif %}
-
You are {% if not is_subscribed %}not{% endif %} subscribed to this package
+
+
+
{% if is_subscribed %}✓{% else %}✕{% endif %}
+
You are {% if not is_subscribed %}not{% endif %} subscribed to this package
{% csrf_token %} diff --git a/src/webview/templates/subscriptions/subscriptions_center.html b/src/webview/templates/subscriptions/subscriptions_center.html index ebd3a6d9..e7b8f968 100644 --- a/src/webview/templates/subscriptions/subscriptions_center.html +++ b/src/webview/templates/subscriptions/subscriptions_center.html @@ -14,7 +14,19 @@

Subscriptions

{% endfor %} {% endif %} +

When a new CVE is suspected to affect packages you have subscribed to, you will receive a notification.

+ +
+

Auto-subscription to maintained packages

+ + {% auto_subscribe_toggle user.profile.auto_subscribe_to_maintained_packages %} +
+
+

Additional packages

+ +

You may subscribe to additional packages here. Those could also be packages you maintain in case you decided to opt out of automatic subscription.

+ {% package_subscriptions package_subscriptions %}
diff --git a/src/webview/templatetags/viewutils.py b/src/webview/templatetags/viewutils.py index 68070bcb..54b54bf6 100644 --- a/src/webview/templatetags/viewutils.py +++ b/src/webview/templatetags/viewutils.py @@ -100,6 +100,11 @@ class PackageSubscriptionsContext(TypedDict): error_message: str | None +class AutoSubscribeContext(TypedDict): + auto_subscribe_enabled: bool + error_message: str | None + + @register.filter def getitem(dictionary: dict, key: str) -> Any | None: return dictionary.get(key) @@ -123,6 +128,17 @@ def package_subscriptions( } +@register.inclusion_tag("subscriptions/components/auto_subscribe.html") +def auto_subscribe_toggle( + auto_subscribe_enabled: bool, + error_message: str | None = None, +) -> AutoSubscribeContext: + return { + "auto_subscribe_enabled": auto_subscribe_enabled, + "error_message": error_message, + } + + @register.inclusion_tag("notifications/components/notification.html") def notification( notification: Notification, diff --git a/src/webview/tests/test_subscriptions.py b/src/webview/tests/test_subscriptions.py index a972a640..cd666485 100644 --- a/src/webview/tests/test_subscriptions.py +++ b/src/webview/tests/test_subscriptions.py @@ -100,6 +100,35 @@ def setUp(self) -> None: parent_evaluation=self.evaluation, ) + # Create a maintainer for the test user + self.user_maintainer = NixMaintainer.objects.create( + github_id=123456, # Same as the user's social account uid + github="testuser", # Same as the user's username + name="Test User", + email="testuser@example.com", + ) + + # Create metadata for a package where test user is maintainer + self.meta3 = NixDerivationMeta.objects.create( + description="Test package maintained by test user", + insecure=False, + available=True, + broken=False, + unfree=False, + unsupported=False, + ) + self.meta3.maintainers.add(self.user_maintainer) + + # Create a package where the test user is a maintainer + self.user_maintained_package = NixDerivation.objects.create( + attribute="neovim", + derivation_path="/nix/store/neovim.drv", + name="neovim-0.9.5", + metadata=self.meta3, + system="x86_64-linux", + parent_evaluation=self.evaluation, + ) + def test_user_subscribes_to_valid_package_success(self) -> None: """Test successful subscription to an existing package""" url = reverse("webview:subscriptions:add") @@ -356,7 +385,7 @@ def test_user_receives_notification_for_subscribed_package_suggestion(self) -> N add_url = reverse("webview:subscriptions:add") self.client.post(add_url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true") - # Create CVE and container - this should trigger automatic linkage and then notifications + # Create CVE and container assigner = Organization.objects.create(uuid=1, short_name="test_org") cve_record = CveRecord.objects.create( cve_id="CVE-2025-0001", @@ -401,6 +430,115 @@ def test_user_receives_notification_for_subscribed_package_suggestion(self) -> N self.assertIn("CVE-2025-0001", notification.message) self.assertFalse(notification.is_read) # Should be unread initially + def test_user_receives_notification_for_maintained_package_suggestion(self) -> None: + """Test that users receive notifications when suggestions affect packages they maintain (automatic subscription)""" + + # Create CVE and container + assigner = Organization.objects.create(uuid=2, short_name="test_org2") + cve_record = CveRecord.objects.create( + cve_id="CVE-2025-0002", + assigner=assigner, + ) + + description = Description.objects.create(value="Test neovim vulnerability") + metric = Metric.objects.create(format="cvssV3_1", raw_cvss_json={}) + affected_product = AffectedProduct.objects.create(package_name="neovim") + affected_product.versions.add( + Version.objects.create(status=Version.Status.AFFECTED, version="0.9.5") + ) + + container = cve_record.container.create( + provider=assigner, + title="Neovim Security Issue", + ) + + container.affected.set([affected_product]) + container.descriptions.set([description]) + container.metrics.set([metric]) + + # Trigger the linkage and notification system manually since pgpubsub triggers won't work in tests + linkage_created = build_new_links(container) + + if linkage_created: + # Get the created proposal and trigger notifications + suggestion = CVEDerivationClusterProposal.objects.get(cve=cve_record) + create_package_subscription_notifications(suggestion) + + # Verify notification appears in notification center context + response = self.client.get(reverse("webview:notifications:center")) + self.assertEqual(response.status_code, 200) + + # Check that notification appears in context + notifications = response.context["notifications"] + self.assertEqual(len(notifications), 1) + + notification = notifications[0] + self.assertEqual(notification.user, self.user) + self.assertIn("neovim", notification.title) + self.assertIn("CVE-2025-0002", notification.message) + self.assertFalse(notification.is_read) # Should be unread initially + + def test_user_does_not_receive_notification_when_auto_subscribe_disabled( + self, + ) -> None: + """Test that users do NOT receive notifications for maintained packages when auto-subscription is disabled""" + # Disable auto-subscription using the view + toggle_url = reverse("webview:subscriptions:toggle_auto_subscribe") + response = self.client.post( + toggle_url, {"action": "disable"}, HTTP_HX_REQUEST="true" + ) + + # Should return 200 with component template for HTMX request + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed( + response, "subscriptions/components/auto_subscribe.html" + ) + + # Verify auto-subscription is disabled in response context + self.assertIn("auto_subscribe_enabled", response.context) + self.assertFalse(response.context["auto_subscribe_enabled"]) + + # Create CVE and container + assigner = Organization.objects.create(uuid=3, short_name="test_org3") + cve_record = CveRecord.objects.create( + cve_id="CVE-2025-0003", + assigner=assigner, + ) + + description = Description.objects.create( + value="Test neovim vulnerability with auto-subscribe disabled" + ) + metric = Metric.objects.create(format="cvssV3_1", raw_cvss_json={}) + affected_product = AffectedProduct.objects.create(package_name="neovim") + affected_product.versions.add( + Version.objects.create(status=Version.Status.AFFECTED, version="0.9.5") + ) + + container = cve_record.container.create( + provider=assigner, + title="Neovim Security Issue", + ) + + container.affected.set([affected_product]) + container.descriptions.set([description]) + container.metrics.set([metric]) + + # Trigger the linkage and notification system manually since pgpubsub triggers won't work in tests + linkage_created = build_new_links(container) + + if linkage_created: + # Get the created proposal and trigger notifications + suggestion = CVEDerivationClusterProposal.objects.get(cve=cve_record) + create_package_subscription_notifications(suggestion) + + # Verify NO notification appears in notification center context + response = self.client.get(reverse("webview:notifications:center")) + self.assertEqual(response.status_code, 200) + + # Check that NO notifications appear in context + notifications = response.context["notifications"] + self.assertEqual(len(notifications), 0) + def test_package_subscription_page_shows_valid_package(self) -> None: """Test that the package subscription page displays correctly for valid packages""" url = reverse(