diff --git a/src/shared/channels.py b/src/shared/channels.py index 9f395234..a33f4316 100644 --- a/src/shared/channels.py +++ b/src/shared/channels.py @@ -40,14 +40,24 @@ class ContainerChannel(TriggerChannel): lock_notifications = True +# We have two channels for CVEDerivationClusterProposal for two usages: +# 1. Caching suggestions: this operation is idempotent and performance sensitive so we disable locking on this channel +# 2. Notifying subscribed users of activity on their packages: this operation is not performance sensitive and we don't want duplicate notifications so we enable locking on this channel @dataclass -class CVEDerivationClusterProposalChannel(TriggerChannel): +class CVEDerivationClusterProposalCacheChannel(TriggerChannel): model = CVEDerivationClusterProposal # We don't need to lock notifications. # If we are caching twice the same proposal, we will just replace it. lock_notifications = False +@dataclass +class CVEDerivationClusterProposalNotificationChannel(TriggerChannel): + model = CVEDerivationClusterProposal + # We don't want to trigger user notifications more than once + lock_notifications = True + + @dataclass class NixpkgsIssueChannel(TriggerChannel): model = NixpkgsIssue diff --git a/src/shared/listeners/__init__.py b/src/shared/listeners/__init__.py index fe2af1aa..d204ef25 100644 --- a/src/shared/listeners/__init__.py +++ b/src/shared/listeners/__init__.py @@ -2,3 +2,5 @@ import shared.listeners.nix_evaluation # noqa import shared.listeners.automatic_linkage # noqa import shared.listeners.cache_suggestions # noqa +import shared.listeners.notify_users # noqa +import shared.listeners.cache_issues # noqa diff --git a/src/shared/listeners/cache_suggestions.py b/src/shared/listeners/cache_suggestions.py index 4f65d376..6b13fdb4 100644 --- a/src/shared/listeners/cache_suggestions.py +++ b/src/shared/listeners/cache_suggestions.py @@ -8,7 +8,7 @@ import pgpubsub from django.db.models import Prefetch -from shared.channels import CVEDerivationClusterProposalChannel +from shared.channels import CVEDerivationClusterProposalCacheChannel from shared.models import NixDerivation, NixMaintainer from shared.models.cached import CachedSuggestions from shared.models.cve import AffectedProduct, Metric, Version @@ -170,7 +170,7 @@ def cache_new_suggestions(suggestion: CVEDerivationClusterProposal) -> None: # CachedSuggestions.objects.filter(pk=new.pk).delete() -@pgpubsub.post_insert_listener(CVEDerivationClusterProposalChannel) +@pgpubsub.post_insert_listener(CVEDerivationClusterProposalCacheChannel) def cache_new_suggestions_following_new_container( old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal ) -> None: diff --git a/src/shared/listeners/notify_users.py b/src/shared/listeners/notify_users.py new file mode 100644 index 00000000..01a5c216 --- /dev/null +++ b/src/shared/listeners/notify_users.py @@ -0,0 +1,76 @@ +import logging + +import pgpubsub +from django.contrib.auth.models import User + +from shared.channels import CVEDerivationClusterProposalNotificationChannel +from shared.models.linkage import CVEDerivationClusterProposal +from webview.models import Notification + +logger = logging.getLogger(__name__) + + +def create_package_subscription_notifications( + suggestion: CVEDerivationClusterProposal, +) -> None: + """ + Create notifications for users subscribed to packages affected by the suggestion. + """ + # Extract all affected package names from the suggestion + affected_packages = list( + suggestion.derivations.values_list("attribute", flat=True).distinct() + ) + + 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( + profile__package_subscriptions__overlap=affected_packages + ).select_related("profile") + + if not subscribed_users.exists(): + logger.debug(f"No subscribed users found for packages: {affected_packages}") + return + + logger.info( + f"Creating notifications for {subscribed_users.count()} users for CVE {suggestion.cve.cve_id}" + ) + + 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 + ] + + # Create notification + try: + 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. " + f"Affected packages: {', '.join(user_affected_packages)}. ", + ) + logger.debug( + f"Created notification for user {user.username} for packages: {user_affected_packages}" + ) + except Exception as e: + logger.error(f"Failed to create notification for user {user.username}: {e}") + + +@pgpubsub.post_insert_listener(CVEDerivationClusterProposalNotificationChannel) +def notify_subscribed_users_following_suggestion_insert( + old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal +) -> None: + """ + Notify users subscribed to packages when a new security suggestion is created. + """ + try: + create_package_subscription_notifications(new) + except Exception as e: + logger.error( + f"Failed to create package subscription notifications for suggestion {new.pk}: {e}" + ) diff --git a/src/shared/management/commands/create_test_cve.py b/src/shared/management/commands/create_test_cve.py new file mode 100644 index 00000000..26bd9d31 --- /dev/null +++ b/src/shared/management/commands/create_test_cve.py @@ -0,0 +1,103 @@ +from argparse import ArgumentParser +from datetime import datetime +from typing import Any + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from shared.models import ( + AffectedProduct, + Container, + CveRecord, + Description, + Organization, + Version, +) + + +class Command(BaseCommand): + help = "Create a test CVE for a specific package" + + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument( + "package_name", + type=str, + help="Package name to create a CVE for", + ) + parser.add_argument( + "--cve-id", + type=str, + help="Custom CVE ID (default: auto-generated)", + ) + + def handle(self, *args: Any, **options: Any) -> None: + package_name = options["package_name"] + cve_id = options.get("cve_id") + + # Generate CVE ID if not provided + if not cve_id: + current_year = datetime.now().year + existing_cves = CveRecord.objects.filter( + cve_id__startswith=f"CVE-{current_year}-" + ).count() + cve_id = f"CVE-{current_year}-{(existing_cves + 1):04d}" + + # Check if CVE already exists + if CveRecord.objects.filter(cve_id=cve_id).exists(): + raise CommandError(f"CVE {cve_id} already exists") + + with transaction.atomic(): + # Create organization + org, _ = Organization.objects.get_or_create( + short_name="TEST_ORG", + defaults={"uuid": "12345678-1234-5678-9abc-123456789012"}, + ) + + # Create CVE record + cve_record = CveRecord.objects.create( + cve_id=cve_id, + state=CveRecord.RecordState.PUBLISHED, + assigner=org, + date_published=datetime.now(), + date_updated=datetime.now(), + triaged=False, + ) + + # Create description + description = Description.objects.create( + lang="en", + value=f"Test vulnerability in {package_name} package.", + ) + + # Create container + container = Container.objects.create( + _type=Container.Type.CNA, + cve=cve_record, + provider=org, + title=f"Vulnerability in {package_name}", + date_public=datetime.now(), + ) + container.descriptions.add(description) + + # Create affected product + affected_product = AffectedProduct.objects.create( + vendor="nixpkgs", + product=package_name, + package_name=package_name, + default_status=AffectedProduct.Status.AFFECTED, + ) + + # Add version constraint + version_affected = Version.objects.create( + status=Version.Status.AFFECTED, + version_type="semver", + less_than="*", + ) + affected_product.versions.add(version_affected) + + # Link to container + container.affected.add(affected_product) + + self.stdout.write( + self.style.SUCCESS(f"Created CVE {cve_id} for {package_name}") + ) diff --git a/src/shared/migrations/0057_nixderivation_shared_nixd_attribu_5fcca7_btree.py b/src/shared/migrations/0057_nixderivation_shared_nixd_attribu_5fcca7_btree.py new file mode 100644 index 00000000..cb428f48 --- /dev/null +++ b/src/shared/migrations/0057_nixderivation_shared_nixd_attribu_5fcca7_btree.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.24 on 2025-10-16 17:12 + +import django.contrib.postgres.indexes +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shared', '0056_packageeditevent_packageeditevent_append_only'), + ] + + operations = [ + migrations.AddIndex( + model_name='nixderivation', + index=django.contrib.postgres.indexes.BTreeIndex(fields=['attribute'], name='shared_nixd_attribu_5fcca7_btree'), + ), + ] diff --git a/src/shared/migrations/0058_remove_cvederivationclusterproposal_pgpubsub_8c7ef_and_more.py b/src/shared/migrations/0058_remove_cvederivationclusterproposal_pgpubsub_8c7ef_and_more.py new file mode 100644 index 00000000..45b95a7c --- /dev/null +++ b/src/shared/migrations/0058_remove_cvederivationclusterproposal_pgpubsub_8c7ef_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.24 on 2025-10-31 13:17 + +from django.db import migrations +import pgtrigger.compiler +import pgtrigger.migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shared', '0057_nixderivation_shared_nixd_attribu_5fcca7_btree'), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name='cvederivationclusterproposal', + name='pgpubsub_8c7ef', + ), + pgtrigger.migrations.AddTrigger( + model_name='cvederivationclusterproposal', + trigger=pgtrigger.compiler.Trigger(name='pgpubsub_6aede', sql=pgtrigger.compiler.UpsertTriggerSql(declare='DECLARE payload JSONB; notification_context_text TEXT;', func='\n \n payload := \'{"app": "shared", "model": "CVEDerivationClusterProposal"}\'::jsonb;\n payload := jsonb_insert(payload, \'{old}\', COALESCE(to_jsonb(OLD), \'null\'));\n payload := jsonb_insert(payload, \'{new}\', COALESCE(to_jsonb(NEW), \'null\'));\n SELECT current_setting(\'pgpubsub.notification_context\', True) INTO notification_context_text;\n IF COALESCE(notification_context_text, \'\') = \'\' THEN\n notification_context_text := \'{}\';\n END IF;\n payload := jsonb_insert(payload, \'{context}\', notification_context_text::jsonb);\n \n \n perform pg_notify(\'pgpubsub_6aede\', payload::text);\n RETURN NEW;\n ', hash='3fc8ef40725c909c32771363fbb4ae378e50302c', operation='INSERT', pgid='pgtrigger_pgpubsub_6aede_fc173', table='shared_cvederivationclusterproposal', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='cvederivationclusterproposal', + trigger=pgtrigger.compiler.Trigger(name='pgpubsub_07e32', sql=pgtrigger.compiler.UpsertTriggerSql(declare='DECLARE payload JSONB; notification_context_text TEXT;', func='\n \n payload := \'{"app": "shared", "model": "CVEDerivationClusterProposal"}\'::jsonb;\n payload := jsonb_insert(payload, \'{old}\', COALESCE(to_jsonb(OLD), \'null\'));\n payload := jsonb_insert(payload, \'{new}\', COALESCE(to_jsonb(NEW), \'null\'));\n SELECT current_setting(\'pgpubsub.notification_context\', True) INTO notification_context_text;\n IF COALESCE(notification_context_text, \'\') = \'\' THEN\n notification_context_text := \'{}\';\n END IF;\n payload := jsonb_insert(payload, \'{context}\', notification_context_text::jsonb);\n \n \n INSERT INTO pgpubsub_notification (channel, payload)\n VALUES (\'pgpubsub_07e32\', payload);\n \n perform pg_notify(\'pgpubsub_07e32\', payload::text);\n RETURN NEW;\n ', hash='eef760200ecd9145771d2d5b5c1d9bc1f96fb2ac', operation='INSERT', pgid='pgtrigger_pgpubsub_07e32_acce7', table='shared_cvederivationclusterproposal', when='AFTER')), + ), + ] diff --git a/src/shared/models/nix_evaluation.py b/src/shared/models/nix_evaluation.py index 53051937..a2ce3fa5 100644 --- a/src/shared/models/nix_evaluation.py +++ b/src/shared/models/nix_evaluation.py @@ -304,6 +304,7 @@ def __str__(self) -> str: class Meta: # type: ignore[override] indexes = [ BTreeIndex(fields=["name"]), + BTreeIndex(fields=["attribute"]), GinIndex(fields=["search_vector"]), ] diff --git a/src/shared/templates/base.html b/src/shared/templates/base.html index b28f5321..e6017df7 100644 --- a/src/shared/templates/base.html +++ b/src/shared/templates/base.html @@ -11,6 +11,7 @@ + {% block extra_head %}{% endblock extra_head %} @@ -40,6 +41,11 @@

{% else %} {{user.username}} {% endif %} + + {% notifications_badge user.profile.unread_notifications_count %} Logout diff --git a/src/webview/management/commands/list_subscriptions.py b/src/webview/management/commands/list_subscriptions.py new file mode 100644 index 00000000..019046d3 --- /dev/null +++ b/src/webview/management/commands/list_subscriptions.py @@ -0,0 +1,37 @@ +from argparse import ArgumentParser +from typing import Any + +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = "List all package subscriptions for a user" + + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument( + "--user", + type=str, + help="Username to list subscriptions for", + required=True, + ) + + def handle(self, *args: Any, **options: Any) -> None: + username = options["user"] + + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise CommandError(f"User '{username}' does not exist") + + subscriptions = user.profile.package_subscriptions + + if not subscriptions: + self.stdout.write(f"No package subscriptions found for user '{username}'") + return + + self.stdout.write(f"Package subscriptions for user '{username}':") + self.stdout.write("-" * 50) + + for i, package in enumerate(subscriptions, 1): + self.stdout.write(f"{i}. {package}") diff --git a/src/webview/migrations/0004_remove_profile_subscriptions_and_more.py b/src/webview/migrations/0004_remove_profile_subscriptions_and_more.py new file mode 100644 index 00000000..3c14f8fe --- /dev/null +++ b/src/webview/migrations/0004_remove_profile_subscriptions_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.24 on 2025-10-16 17:12 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webview', '0003_profile_unread_notifications_count'), + ] + + operations = [ + migrations.RemoveField( + model_name='profile', + name='subscriptions', + ), + migrations.AddField( + 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 (e.g., 'firefox', 'chromium')", size=None), + ), + ] diff --git a/src/webview/models.py b/src/webview/models.py index 3c11a37e..7f5a2f7d 100644 --- a/src/webview/models.py +++ b/src/webview/models.py @@ -2,12 +2,11 @@ from typing import Any from django.contrib.auth.models import User +from django.contrib.postgres import fields from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver -from shared.models import NixpkgsIssue - class Profile(models.Model): """ @@ -16,8 +15,13 @@ class Profile(models.Model): """ user = models.OneToOneField(User, on_delete=models.CASCADE) - subscriptions = models.ManyToManyField(NixpkgsIssue, related_name="subscribers") unread_notifications_count = models.PositiveIntegerField(default=0) + package_subscriptions = fields.ArrayField( + models.CharField(max_length=255), + default=list, + blank=True, + help_text="Package attribute names this user has subscribed to (e.g., 'firefox', 'chromium')", + ) def recalculate_unread_notifications_count(self) -> None: """Recalculate and update the unread notifications count from the database.""" diff --git a/src/webview/static/eye.svg b/src/webview/static/eye.svg new file mode 100644 index 00000000..04f70ffd --- /dev/null +++ b/src/webview/static/eye.svg @@ -0,0 +1,6 @@ + + + +eye + + diff --git a/src/webview/static/subscriptions.css b/src/webview/static/subscriptions.css new file mode 100644 index 00000000..f429d33c --- /dev/null +++ b/src/webview/static/subscriptions.css @@ -0,0 +1,186 @@ +:root { + --subscription-error-color: #d31919; + --subscription-subscribe-color: #48a564; + --subscription-subscribe-background-color: #d9fce3; + --subscription-unsubscribe-color: #d31919; +} + +.subscriptions-navbar-icon { + display: block; + height: 2em; + padding-left: 1.5em; + background-size: contain; + background-position: left; + background-repeat: no-repeat; + background-image: url("/static/eye.svg"); +} + +.subscriptions-center-message, +.package-subscriptions .message { + background: var(--subscription-error-color); + color: white; + font-weight: bold; + padding: 0.5em; + margin: 0.5em; + border-radius: 0.2em; +} + +.subscription-center-header > h1 { + margin: 1em 0; + font-size: 2em; + font-weight: bold; +} + +.package-subscriptions h2 { + margin: 0.5em 0; + font-size: 1.5em; + font-weight: bold; +} + +.package-subscriptions .subscribe-form { + display: flex; + justify-content: flex-start; + align-items: stretch; + gap: 0.2em; +} + +.package-subscriptions .subscribe-form input { + flex-basis: 20rem; + padding: 0.2em; +} + +.package-subscriptions button { + font-size: 0.8em; + border: none; + border-radius: 0.2em; + padding: 0.4em 1em; + cursor: pointer; +} + +.package-subscriptions .subscribe-btn { + background: var(--subscription-subscribe-color); + color: white; +} + +.package-subscriptions .unsubscribe-btn { + background: #eee; + color: var(--subscription-unsubscribe-color); + font-weight: bold; +} + +.package-subscriptions ul.packages-list { + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5em; + list-style-type: none; +} + +.package-subscriptions ul.packages-list li { + display: flex; + gap: 0.2em; +} + +.package-subscriptions ul.packages-list li .package-name { + padding: 0.2em; + font-weight: bold; +} + +/* Individual package subscription pages */ + +.package-subscription header { + display: flex; + flex-direction: column; + gap: 0em; + margin: 1em 0; +} + +.package-subscription header h1 { + margin: 0; + font-size: 2em; + font-weight: bold; +} + +.package-subscription .package-not-found { + background: var(--subscription-error-color); + color: white; + padding: 2em; + margin: 2em; + border-radius: 0.2em; +} + +.package-subscription .package-not-found h2 { + margin: 0; + font-size: 1.5em; + font-weight: bold; +} + +.package-subscription .package-detail { + padding: 2em; + margin: 2em; + border-radius: 0.2em; + border: 1px solid var(--grey); +} + +.package-subscription .package-detail h2 { + margin: 0; + font-size: 1.5em; + font-weight: bold; +} + +.package-subscription .subscription-panel { + display: flex; + justify-content: space-between; + align-items: center; + gap: 2em; + padding: 1em 2em; + border-radius: 0.2em; +} + +.package-subscription .subscription-panel.subscribed { + background: var(--subscription-subscribe-background-color); +} + +.package-subscription .subscription-panel.unsubscribed { + background: #eee; +} + +.package-subscription .subscription-panel button { + border: none; + border-radius: 0.2em; + padding: 0.4em 1em; + cursor: pointer; +} + +.package-subscription .subscribed button { + color: var(--subscription-unsubscribe-color); + background: white; + border: solid 1px var(--subscription-unsubscribe-color); + font-weight: bold; +} + +.package-subscription .unsubscribed button { + background: var(--subscription-subscribe-color); + color: white; + font-weight: bold; +} + +.package-subscription .subscription-status { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1em; + font-weight: bold; +} + +.package-subscription .subscription-status .status-icon { + font-size: 3em; +} + +.package-subscription .subscribed .subscription-status { + color: var(--subscription-subscribe-color); +} + +.package-subscription .unsubscribed .subscription-status { + color: #777; +} diff --git a/src/webview/subscriptions/__init__.py b/src/webview/subscriptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/webview/subscriptions/urls.py b/src/webview/subscriptions/urls.py new file mode 100644 index 00000000..3074bb17 --- /dev/null +++ b/src/webview/subscriptions/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .views import ( + AddSubscriptionView, + PackageSubscriptionView, + RemoveSubscriptionView, + SubscriptionCenterView, +) + +app_name = "subscriptions" + +urlpatterns = [ + path("", SubscriptionCenterView.as_view(), name="center"), + path("add/", AddSubscriptionView.as_view(), name="add"), + path("remove/", RemoveSubscriptionView.as_view(), name="remove"), + path( + "package//", PackageSubscriptionView.as_view(), name="package" + ), +] diff --git a/src/webview/subscriptions/views.py b/src/webview/subscriptions/views.py new file mode 100644 index 00000000..7b1527c0 --- /dev/null +++ b/src/webview/subscriptions/views.py @@ -0,0 +1,227 @@ +from typing import Any + +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.views.generic import TemplateView + +from shared.models import NixDerivation + + +class SubscriptionCenterView(LoginRequiredMixin, TemplateView): + template_name = "subscriptions/subscriptions_center.html" + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + context["package_subscriptions"] = ( + self.request.user.profile.package_subscriptions + ) + return context + + +def validate_package_exists(package_name: str) -> tuple[bool, str]: + """ + Validate if a package exists and return validation result with error message. + + Returns: + tuple: (is_valid, error_message) + """ + # Sanitize input + package_name = package_name.strip() + + if not package_name: + return False, "Package name cannot be empty." + + # Check if package exists in NixDerivation + if not NixDerivation.objects.filter(attribute=package_name).exists(): + return False, f"Package '{package_name}' does not exist." + + return True, "" + + +class AddSubscriptionView(LoginRequiredMixin, TemplateView): + """Add a package subscription for the user.""" + + template_name = "subscriptions/components/packages.html" + + def post(self, request: HttpRequest) -> HttpResponse: + """Add a package subscription.""" + package_name = request.POST.get("package_name", "").strip() + + # Validate package exists + is_valid, error_message = validate_package_exists(package_name) + if not is_valid: + return self._handle_error(request, error_message) + + # Check if already subscribed + profile = request.user.profile + if package_name in profile.package_subscriptions: + return self._handle_error( + request, f"You are already subscribed to '{package_name}'." + ) + + # Add subscription + profile.package_subscriptions.append(package_name) + profile.package_subscriptions.sort() + profile.save(update_fields=["package_subscriptions"]) + + # Handle HTMX vs standard request + if request.headers.get("HX-Request"): + return self.render_to_response( + { + "package_subscriptions": profile.package_subscriptions, + } + ) + 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( + { + "package_subscriptions": request.user.profile.package_subscriptions, + "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 RemoveSubscriptionView(LoginRequiredMixin, TemplateView): + """Remove a package subscription for the user.""" + + template_name = "subscriptions/components/packages.html" + + def post(self, request: HttpRequest) -> HttpResponse: + """Remove a package subscription.""" + package_name = request.POST.get("package_name", "").strip() + + if not package_name: + return self._handle_error(request, "Package name is required.") + + profile = request.user.profile + + # Check if subscribed + if package_name not in profile.package_subscriptions: + return self._handle_error( + request, f"You are not subscribed to '{package_name}'." + ) + + # Remove subscription + profile.package_subscriptions.remove(package_name) + profile.save(update_fields=["package_subscriptions"]) + + # Handle HTMX vs standard request + if request.headers.get("HX-Request"): + return self.render_to_response( + { + "package_subscriptions": profile.package_subscriptions, + } + ) + 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( + { + "package_subscriptions": request.user.profile.package_subscriptions, + "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.""" + + template_name = "subscriptions/package_subscription.html" + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + package_name = kwargs.get("package_name", "").strip() + + # Validate package exists + is_valid, error_message = validate_package_exists(package_name) + context["package_name"] = package_name + context["package_exists"] = is_valid + context["error_message"] = error_message if not is_valid else None + + # Check if user is subscribed to this package + if is_valid and hasattr(self.request.user, "profile"): + context["is_subscribed"] = ( + package_name in self.request.user.profile.package_subscriptions + ) + else: + context["is_subscribed"] = False + + return context + + def post(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: + """Handle subscribe/unsubscribe actions for a specific package.""" + package_name = kwargs.get("package_name", "").strip() + action = request.POST.get("action", "") + + # Validate package exists + is_valid, error_message = validate_package_exists(package_name) + if not is_valid: + return self._handle_error(request, package_name, error_message) + + profile = request.user.profile + + if action == "subscribe": + # Check if already subscribed + if package_name in profile.package_subscriptions: + return self._handle_error( + request, + package_name, + f"You are already subscribed to '{package_name}'.", + ) + + # Add subscription + profile.package_subscriptions.append(package_name) + profile.package_subscriptions.sort() + profile.save(update_fields=["package_subscriptions"]) + + elif action == "unsubscribe": + # Check if subscribed + if package_name not in profile.package_subscriptions: + return self._handle_error( + request, + package_name, + f"You are not subscribed to '{package_name}'.", + ) + + # Remove subscription + profile.package_subscriptions.remove(package_name) + profile.save(update_fields=["package_subscriptions"]) + + else: + return self._handle_error(request, package_name, "Invalid action.") + + # Redirect back to the same page to show updated state + return redirect( + reverse( + "webview:subscriptions:package", kwargs={"package_name": package_name} + ) + ) + + def _handle_error( + self, request: HttpRequest, package_name: str, error_message: str + ) -> HttpResponse: + """Handle error responses for the package subscription page.""" + messages.error(request, error_message) + return redirect( + reverse( + "webview:subscriptions:package", kwargs={"package_name": package_name} + ) + ) diff --git a/src/webview/templates/subscriptions/components/packages.html b/src/webview/templates/subscriptions/components/packages.html new file mode 100644 index 00000000..54ed915d --- /dev/null +++ b/src/webview/templates/subscriptions/components/packages.html @@ -0,0 +1,37 @@ +{% load viewutils %} +
+ +

Packages

+ + + {% if error_message %} +
{{ error_message }}
+ {% endif %} + + + + + + {% if package_subscriptions %} +
    + {% for package in package_subscriptions %} +
  • +
    + {% csrf_token %} + + +
    +
    {{ package }}
    +
  • + {% endfor %} +
+ {% else %} +
+ You haven't subscribed to any packages yet. +
+ {% endif %} +
diff --git a/src/webview/templates/subscriptions/package_subscription.html b/src/webview/templates/subscriptions/package_subscription.html new file mode 100644 index 00000000..d0b20585 --- /dev/null +++ b/src/webview/templates/subscriptions/package_subscription.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% load viewutils %} + +{% block content %} + +
+ +
+ ← Subscription Center +

Package Subscription

+
+ + + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + + {% if not package_exists %} + +
+

Package Not Found

+
Package "{{ package_name }}" could not be found.
+
+ + {% else %} +
+

{{ 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
+
+
+ {% csrf_token %} + {% if is_subscribed %} + + + {% else %} + + + {% endif %} +
+
+
+ {% endif %} + +
+{% endblock content %} diff --git a/src/webview/templates/subscriptions/subscriptions_center.html b/src/webview/templates/subscriptions/subscriptions_center.html new file mode 100644 index 00000000..ebd3a6d9 --- /dev/null +++ b/src/webview/templates/subscriptions/subscriptions_center.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% load viewutils %} + +{% block content %} + +
+

Subscriptions

+
+ + +{% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} +{% endif %} + +
+ {% package_subscriptions package_subscriptions %} +
+ +{% endblock content %} diff --git a/src/webview/templatetags/viewutils.py b/src/webview/templatetags/viewutils.py index fe116bc2..68070bcb 100644 --- a/src/webview/templatetags/viewutils.py +++ b/src/webview/templatetags/viewutils.py @@ -95,6 +95,11 @@ class NotificationsBadgeContext(TypedDict): oob_update: bool | None +class PackageSubscriptionsContext(TypedDict): + package_subscriptions: list[str] + error_message: str | None + + @register.filter def getitem(dictionary: dict, key: str) -> Any | None: return dictionary.get(key) @@ -107,6 +112,17 @@ def getdrvname(drv: dict) -> str: return f"{name} {hash[:8]}" +@register.inclusion_tag("subscriptions/components/packages.html") +def package_subscriptions( + package_subscriptions: list[str], + error_message: str | None = None, +) -> PackageSubscriptionsContext: + return { + "package_subscriptions": package_subscriptions, + "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 new file mode 100644 index 00000000..a972a640 --- /dev/null +++ b/src/webview/tests/test_subscriptions.py @@ -0,0 +1,477 @@ +from allauth.socialaccount.models import SocialAccount +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse + +from shared.listeners.automatic_linkage import build_new_links +from shared.listeners.notify_users import create_package_subscription_notifications +from shared.models.cve import ( + AffectedProduct, + CveRecord, + Description, + Metric, + Organization, + Version, +) +from shared.models.linkage import CVEDerivationClusterProposal +from shared.models.nix_evaluation import ( + NixChannel, + NixDerivation, + NixDerivationMeta, + NixEvaluation, + NixMaintainer, +) + + +class SubscriptionTests(TestCase): + def setUp(self) -> None: + # Create test user with social account + self.user = User.objects.create_user(username="testuser", password="testpass") + self.user.is_staff = True + self.user.save() + + SocialAccount.objects.get_or_create( + user=self.user, + provider="github", + uid="123456", + extra_data={"login": "testuser"}, + ) + + self.client = Client() + self.client.login(username="testuser", password="testpass") + + # Create test NixDerivation data for package validation + self.maintainer = NixMaintainer.objects.create( + github_id=123, + github="testmaintainer", + name="Test Maintainer", + email="test@example.com", + ) + self.meta = NixDerivationMeta.objects.create( + description="Test package", + insecure=False, + available=True, + broken=False, + unfree=False, + unsupported=False, + ) + self.meta.maintainers.add(self.maintainer) + + self.evaluation = NixEvaluation.objects.create( + channel=NixChannel.objects.create( + staging_branch="release-24.05", + channel_branch="nixos-24.05", + head_sha1_commit="deadbeef", + state=NixChannel.ChannelState.STABLE, + release_version="24.05", + repository="https://github.com/NixOS/nixpkgs", + ), + commit_sha1="deadbeef", + state=NixEvaluation.EvaluationState.COMPLETED, + ) + + # Create valid packages that can be subscribed to + self.valid_package1 = NixDerivation.objects.create( + attribute="firefox", + derivation_path="/nix/store/firefox.drv", + name="firefox-120.0", + metadata=self.meta, + system="x86_64-linux", + parent_evaluation=self.evaluation, + ) + + # Create separate metadata for chromium + self.meta2 = NixDerivationMeta.objects.create( + description="Test chromium package", + insecure=False, + available=True, + broken=False, + unfree=False, + unsupported=False, + ) + self.meta2.maintainers.add(self.maintainer) + + self.valid_package2 = NixDerivation.objects.create( + attribute="chromium", + derivation_path="/nix/store/chromium.drv", + name="chromium-119.0", + metadata=self.meta2, + 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") + response = self.client.post(url, {"package_name": "firefox"}) + + # Should redirect for non-HTMX request + self.assertEqual(response.status_code, 302) + self.assertIn("subscriptions", response.url) + + # Follow redirect and check subscription center context + response = self.client.get(response.url) + self.assertEqual(response.status_code, 200) + + # Verify subscription appears in context + self.assertIn("package_subscriptions", response.context) + self.assertIn("firefox", response.context["package_subscriptions"]) + + def test_user_subscribes_to_invalid_package_fails(self) -> None: + """Test subscription fails for non-existent package""" + url = reverse("webview:subscriptions:add") + response = self.client.post(url, {"package_name": "nonexistent-package"}) + + # Should redirect for non-HTMX request + self.assertEqual(response.status_code, 302) + + # Follow redirect and check for error message and context + response = self.client.get(response.url) + self.assertEqual(response.status_code, 200) + + # Check that error message is in Django messages + messages = list(response.context["messages"]) + self.assertTrue(any("does not exist" in str(message) for message in messages)) + + # Verify no invalid subscription in context + self.assertIn("package_subscriptions", response.context) + self.assertEqual(response.context["package_subscriptions"], []) + + def test_user_subscribes_to_valid_package_success_htmx(self) -> None: + """Test successful subscription to an existing package via HTMX""" + url = reverse("webview:subscriptions:add") + response = self.client.post( + url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true" + ) + + # Should return 200 with component template for HTMX request + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/components/packages.html") + + # Verify subscription appears in context + self.assertIn("package_subscriptions", response.context) + self.assertIn("firefox", response.context["package_subscriptions"]) + + # Should not have error message + self.assertNotIn("error_message", response.context) + + def test_user_subscribes_to_invalid_package_fails_htmx(self) -> None: + """Test subscription fails for non-existent package via HTMX""" + url = reverse("webview:subscriptions:add") + response = self.client.post( + url, {"package_name": "nonexistent-package"}, HTTP_HX_REQUEST="true" + ) + + # Should return 200 with component template for HTMX request + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/components/packages.html") + + # Check that error message is in context + self.assertIn("error_message", response.context) + self.assertIn("does not exist", response.context["error_message"]) + + # Verify no invalid subscription in context + self.assertIn("package_subscriptions", response.context) + self.assertNotIn( + "nonexistent-package", response.context["package_subscriptions"] + ) + + def test_user_subscribes_to_empty_package_name_fails_htmx(self) -> None: + """Test subscription fails for empty package name via HTMX""" + url = reverse("webview:subscriptions:add") + response = self.client.post(url, {"package_name": ""}, HTTP_HX_REQUEST="true") + + # Should return 200 with component template for HTMX request + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/components/packages.html") + + # Check that error message is in context + self.assertIn("error_message", response.context) + self.assertIn("cannot be empty", response.context["error_message"]) + + # Verify no subscriptions in context + self.assertIn("package_subscriptions", response.context) + self.assertEqual(response.context["package_subscriptions"], []) + + def test_user_cannot_subscribe_to_same_package_twice_htmx(self) -> None: + """Test duplicate subscription prevention via HTMX""" + url = reverse("webview:subscriptions:add") + + # First subscription should succeed + response = self.client.post( + url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true" + ) + self.assertEqual(response.status_code, 200) + self.assertIn("firefox", response.context["package_subscriptions"]) + + # Second subscription to same package should fail + response = self.client.post( + url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true" + ) + + # Should return 200 with component template + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/components/packages.html") + + # Check that error message is in context + self.assertIn("error_message", response.context) + self.assertIn("already subscribed", response.context["error_message"]) + + # Verify firefox still appears only once in context + self.assertIn("package_subscriptions", response.context) + self.assertIn("firefox", response.context["package_subscriptions"]) + + def test_user_unsubscribes_from_package_success_htmx(self) -> None: + """Test successful unsubscription via HTMX""" + # First subscribe to a package via HTMX + add_url = reverse("webview:subscriptions:add") + self.client.post(add_url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true") + + # Now unsubscribe via HTMX + remove_url = reverse("webview:subscriptions:remove") + response = self.client.post( + remove_url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true" + ) + + # Should return 200 with component template for HTMX request + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/components/packages.html") + + # Verify subscription was removed from context + self.assertIn("package_subscriptions", response.context) + self.assertNotIn("firefox", response.context["package_subscriptions"]) + self.assertEqual(response.context["package_subscriptions"], []) + + # Should not have error message + self.assertNotIn("error_message", response.context) + + def test_user_cannot_unsubscribe_from_non_subscribed_package_htmx(self) -> None: + """Test unsubscription fails for packages not subscribed to via HTMX""" + url = reverse("webview:subscriptions:remove") + response = self.client.post( + url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true" + ) + + # Should return 200 with component template for HTMX request + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/components/packages.html") + + # Check that error message is in context + self.assertIn("error_message", response.context) + self.assertIn("not subscribed", response.context["error_message"]) + + # Verify empty subscriptions in context + self.assertIn("package_subscriptions", response.context) + self.assertEqual(response.context["package_subscriptions"], []) + + def test_subscription_center_shows_user_subscriptions(self) -> None: + """Test that the center displays user's current subscriptions""" + # First add some subscriptions via HTMX + add_url = reverse("webview:subscriptions:add") + self.client.post(add_url, {"package_name": "firefox"}, HTTP_HX_REQUEST="true") + + # Add second package + self.client.post(add_url, {"package_name": "chromium"}, HTTP_HX_REQUEST="true") + + # Check subscription center shows both subscriptions + response = self.client.get(reverse("webview:subscriptions:center")) + self.assertEqual(response.status_code, 200) + + # Check context contains both subscriptions + self.assertIn("package_subscriptions", response.context) + subscriptions = response.context["package_subscriptions"] + self.assertIn("firefox", subscriptions) + self.assertIn("chromium", subscriptions) + self.assertEqual(len(subscriptions), 2) + + def test_subscription_center_shows_empty_state(self) -> None: + """Test empty state when user has no subscriptions""" + response = self.client.get(reverse("webview:subscriptions:center")) + self.assertEqual(response.status_code, 200) + + # Check context shows empty subscriptions + self.assertIn("package_subscriptions", response.context) + self.assertEqual(response.context["package_subscriptions"], []) + + def test_subscription_center_requires_login(self) -> None: + """Test that subscription center redirects when not logged in""" + # Logout the user + self.client.logout() + + response = self.client.get(reverse("webview:subscriptions:center")) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + # Test add endpoint also requires login + response = self.client.post( + reverse("webview:subscriptions:add"), {"package_name": "firefox"} + ) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + # Test remove endpoint also requires login + response = self.client.post( + reverse("webview:subscriptions:remove"), {"package_name": "firefox"} + ) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + # Test HTMX requests also require login + response = self.client.post( + reverse("webview:subscriptions:add"), + {"package_name": "firefox"}, + HTTP_HX_REQUEST="true", + ) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + response = self.client.post( + reverse("webview:subscriptions:remove"), + {"package_name": "firefox"}, + HTTP_HX_REQUEST="true", + ) + self.assertEqual(response.status_code, 302) + self.assertIn("login", response.url) + + def test_user_unsubscribes_from_empty_package_name_fails_htmx(self) -> None: + """Test unsubscription fails for empty package name via HTMX""" + url = reverse("webview:subscriptions:remove") + response = self.client.post(url, {"package_name": ""}, HTTP_HX_REQUEST="true") + + # Should return 200 with component template for HTMX request + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/components/packages.html") + + # Check that error message is in context + self.assertIn("error_message", response.context) + self.assertIn("required", response.context["error_message"]) + + # Verify empty subscriptions in context + self.assertIn("package_subscriptions", response.context) + self.assertEqual(response.context["package_subscriptions"], []) + + def test_user_receives_notification_for_subscribed_package_suggestion(self) -> None: + """Test that users receive notifications when suggestions affect their subscribed packages""" + # User subscribes to firefox package + 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 + assigner = Organization.objects.create(uuid=1, short_name="test_org") + cve_record = CveRecord.objects.create( + cve_id="CVE-2025-0001", + assigner=assigner, + ) + + description = Description.objects.create(value="Test firefox vulnerability") + metric = Metric.objects.create(format="cvssV3_1", raw_cvss_json={}) + affected_product = AffectedProduct.objects.create(package_name="firefox") + affected_product.versions.add( + Version.objects.create(status=Version.Status.AFFECTED, version="120.0") + ) + + container = cve_record.container.create( + provider=assigner, + title="Firefox 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("firefox", notification.title) + self.assertIn("CVE-2025-0001", notification.message) + self.assertFalse(notification.is_read) # Should be unread initially + + def test_package_subscription_page_shows_valid_package(self) -> None: + """Test that the package subscription page displays correctly for valid packages""" + url = reverse( + "webview:subscriptions:package", kwargs={"package_name": "firefox"} + ) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/package_subscription.html") + + # Check context + self.assertEqual(response.context["package_name"], "firefox") + self.assertTrue(response.context["package_exists"]) + self.assertFalse(response.context["is_subscribed"]) + self.assertIsNone(response.context["error_message"]) + + def test_package_subscription_page_shows_invalid_package(self) -> None: + """Test that the package subscription page shows error for invalid packages""" + url = reverse( + "webview:subscriptions:package", kwargs={"package_name": "nonexistent"} + ) + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "subscriptions/package_subscription.html") + + # Check context + self.assertEqual(response.context["package_name"], "nonexistent") + self.assertFalse(response.context["package_exists"]) + self.assertFalse(response.context["is_subscribed"]) + self.assertIsNotNone(response.context["error_message"]) + self.assertIn("does not exist", response.context["error_message"]) + + def test_package_subscription_page_subscribe_action(self) -> None: + """Test subscribing to a package via the package subscription page""" + url = reverse( + "webview:subscriptions:package", kwargs={"package_name": "firefox"} + ) + response = self.client.post(url, {"action": "subscribe"}) + + # Should redirect back to the same page + self.assertEqual(response.status_code, 302) + self.assertIn("firefox", response.url) + + # Follow redirect and check subscription status + response = self.client.get(response.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context["is_subscribed"]) + + def test_package_subscription_page_unsubscribe_action(self) -> None: + """Test unsubscribing from a package via the package subscription page""" + # First subscribe to the package + self.user.profile.package_subscriptions.append("firefox") + self.user.profile.save(update_fields=["package_subscriptions"]) + + url = reverse( + "webview:subscriptions:package", kwargs={"package_name": "firefox"} + ) + + # Verify initially subscribed + response = self.client.get(url) + self.assertTrue(response.context["is_subscribed"]) + + # Unsubscribe + response = self.client.post(url, {"action": "unsubscribe"}) + + # Should redirect back to the same page + self.assertEqual(response.status_code, 302) + self.assertIn("firefox", response.url) + + # Follow redirect and check subscription status + response = self.client.get(response.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.context["is_subscribed"]) diff --git a/src/webview/urls.py b/src/webview/urls.py index 687c0a19..5680c81d 100644 --- a/src/webview/urls.py +++ b/src/webview/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ path("", HomeView.as_view(), name="home"), path("notifications/", include("webview.notifications.urls")), + path("subscriptions/", include("webview.subscriptions.urls")), path("issues/", NixpkgsIssueListView.as_view(), name="issue_list"), re_path( r"^issues/(?PNIXPKGS-[0-9]{4}-[0-9]{4,19})$",