Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions nix/configuration.nix
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,7 @@ in
shared.channels.NixChannelInsertChannel \
shared.channels.NixChannelUpdateChannel \
shared.channels.ContainerChannel \
shared.channels.CVEDerivationClusterProposalCacheChannel \
shared.channels.CVEDerivationClusterProposalNotificationChannel \
shared.channels.CVEDerivationClusterProposalChannel \
'';
};

Expand Down
8 changes: 1 addition & 7 deletions src/shared/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,7 @@ class ContainerChannel(TriggerChannel):


@dataclass
class CVEDerivationClusterProposalCacheChannel(TriggerChannel):
model = CVEDerivationClusterProposal
lock_notifications = True


@dataclass
class CVEDerivationClusterProposalNotificationChannel(TriggerChannel):
class CVEDerivationClusterProposalChannel(TriggerChannel):
model = CVEDerivationClusterProposal
lock_notifications = True

Expand Down
1 change: 1 addition & 0 deletions src/shared/listeners/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
import shared.listeners.automatic_linkage # noqa
import shared.listeners.cache_suggestions # noqa
import shared.listeners.notify_users # noqa
import shared.listeners.cache_and_notify # noqa
30 changes: 30 additions & 0 deletions src/shared/listeners/cache_and_notify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import logging

import pgpubsub

from shared.channels import CVEDerivationClusterProposalChannel
from shared.listeners.cache_suggestions import cache_new_suggestions
from shared.listeners.notify_users import create_package_subscription_notifications
from shared.models.linkage import CVEDerivationClusterProposal

logger = logging.getLogger(__name__)

# FIXME: this breaks the insert listener, let's report it upstream.
# @pgpubsub.post_update_listener(CVEDerivationClusterProposalChannel)
# def expire_cached_suggestions(old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal) -> None:
# if new.status != CVEDerivationClusterProposal.Status.PENDING:
# CachedSuggestions.objects.filter(pk=new.pk).delete()

Check notice

Code scanning / CodeQL

Commented-out code Note

This comment appears to contain commented-out code.
Comment on lines +14 to +16


@pgpubsub.post_insert_listener(CVEDerivationClusterProposalChannel)
def cache_new_suggestions_following_new_container(
old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal
) -> None:
logger.info(f"Cache and notify for suggestion {new.pk}")
cache_new_suggestions(new)
try:
create_package_subscription_notifications(new)
except Exception as e:
logger.error(
f"Failed to create package subscription notifications for suggestion {new.pk}: {e}"
)
16 changes: 0 additions & 16 deletions src/shared/listeners/cache_suggestions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@
from itertools import chain
from typing import Any, overload

import pgpubsub
from django.db.models import Prefetch, Q
from pydantic import BaseModel, field_serializer

from shared.channels import CVEDerivationClusterProposalCacheChannel
from shared.models import NixDerivation, NixMaintainer
from shared.models.cached import CachedSuggestions
from shared.models.cve import AffectedProduct, Metric, Reference, Version
Expand Down Expand Up @@ -263,20 +261,6 @@ def cache_new_suggestions(suggestion: CVEDerivationClusterProposal) -> None:
logger.info("CVE '%s' suggestion cache updated", suggestion.cve.cve_id)


# FIXME: this breaks the insert listener, let's report it upstream.
# @pgpubsub.post_update_listener(CVEDerivationClusterProposalChannel)
# def expire_cached_suggestions(old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal) -> None:
# if new.status != CVEDerivationClusterProposal.Status.PENDING:
# CachedSuggestions.objects.filter(pk=new.pk).delete()


@pgpubsub.post_insert_listener(CVEDerivationClusterProposalCacheChannel)
def cache_new_suggestions_following_new_container(
old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal
) -> None:
cache_new_suggestions(new)


def is_version_affected(version_statuses: list[str]) -> Version.Status:
"""
Returns the highest priority status from the list of version constraints.
Expand Down
127 changes: 113 additions & 14 deletions src/shared/listeners/notify_users.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import logging
from urllib.parse import urljoin

import pgpubsub
from django.conf import settings
from django.contrib.auth.models import User
from django.core.mail import send_mail
from django.urls import reverse

from shared.channels import CVEDerivationClusterProposalNotificationChannel
from shared.models.linkage import CVEDerivationClusterProposal
from webview.models import Profile
from webview.models import SuggestionNotification as Notification
from webview.notifications.context import NotificationContext

logger = logging.getLogger(__name__)

Expand All @@ -19,6 +22,10 @@ def create_package_subscription_notifications(
and for maintainers of those packages (if they have auto-subscribe enabled).
"""

# FIXME(@florentc): These queries are no longer related. We should use the
# cached suggestions when possible. This was done back when notifying could
# happen before caching.

# Query package attributes directly from the suggestion's derivations
affected_packages = list(
suggestion.derivations.values_list("attribute", flat=True).distinct()
Expand Down Expand Up @@ -77,21 +84,113 @@ def create_package_subscription_notifications(
)
notifications.append(notification)
logger.debug(f"Created notification for user {user.username}")

# Send email notification if enabled
try:
send_notification_email(user, notification)
except Exception as e:
logger.error(
f"Failed to send email notification to user {user.username}: {e}"
)

except Exception as e:
logger.error(f"Failed to create notification for user {user.username}: {e}")
return notifications


@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}"
def send_notification_email(user: User, notification: Notification) -> None:
"""Send an email notification to a user about a new CVE suggestion."""
if not (user.profile.receive_email_notifications):
return

email = (
user.profile.notification_email
if user.profile.notification_email
else user.profile.github_email
)

if not email:
logger.info(f"Could not send email notification to {user.username}: no address")
return

# Reuse existing NotificationContext logic for package matching
context = NotificationContext(notification=notification, user_profile=user.profile)

suggestion = notification.suggestion
subject = f"New security notification: {suggestion.cve.cve_id}"

message_parts = [
"Hello,",
"",
"A new security vulnerability has been identified that may affect packages you follow or maintain:",
"",
f"CVE: {suggestion.cve.cve_id}",
f"Details: https://nvd.nist.gov/vuln/detail/{suggestion.cve.cve_id}",
"",
]

if suggestion.cached and suggestion.cached.payload.get("affected_products"):
affected_products = suggestion.cached.payload["affected_products"]
if affected_products:
message_parts.extend(
[
"Affected products:",
]
)
for package_name, ap in affected_products.items():
version_info = []
for status, vc_str in ap.get("version_constraints", []):
if status == "unaffected":
version_info.append(f"{vc_str} (unaffected)")
else:
version_info.append(vc_str)

if version_info:
message_parts.append(
f" - {package_name}: {', '.join(version_info)}"
)
else:
message_parts.append(f" - {package_name}")

message_parts.append("")

if context.matching_subscribed_packages:
message_parts.extend(
[
"Packages you follow that may be affected:",
*[f" - {pkg}" for pkg in context.matching_subscribed_packages.keys()],
"",
]
)

if context.matching_maintained_packages:
message_parts.extend(
[
"Packages you maintain that may be affected:",
*[f" - {pkg}" for pkg in context.matching_maintained_packages.keys()],
"",
]
)

message_parts.extend(
[
f"View all notifications: {urljoin(str(settings.BASE_URL), reverse('webview:notifications:center'))}",
f"Review suggestion details: {urljoin(str(settings.BASE_URL), reverse('webview:suggestion:detail', kwargs={'suggestion_id': suggestion.pk}))}",
"",
"Best regards,",
"Nix Security Tracker",
"",
f"Manage email notification preferences: {urljoin(str(settings.BASE_URL), reverse('webview:subscriptions:center'))}",
]
)

message = "\n".join(message_parts)

send_mail(
subject=subject,
message=message,
from_email=None, # Use default
recipient_list=[email],
fail_silently=False,
)
logger.info(f"Email notification sent to {user.username} at {email}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.9 on 2026-04-29 11:16

import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('shared', '0082_alter_derivationclusterproposallink_derivation_and_more'),
]

operations = [
pgtrigger.migrations.RemoveTrigger(
model_name='cvederivationclusterproposal',
name='pgpubsub_07e32',
),
pgtrigger.migrations.RemoveTrigger(
model_name='cvederivationclusterproposal',
name='pgpubsub_6aede',
),
pgtrigger.migrations.AddTrigger(
model_name='cvederivationclusterproposal',
trigger=pgtrigger.compiler.Trigger(name='pgpubsub_8c7ef', 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_8c7ef\', payload);\n \n perform pg_notify(\'pgpubsub_8c7ef\', payload::text);\n RETURN NEW;\n ', hash='f992274d71c90db51227a6220854697b1f77b60e', operation='INSERT', pgid='pgtrigger_pgpubsub_8c7ef_29d5d', table='shared_cvederivationclusterproposal', when='AFTER')),
),
]
23 changes: 23 additions & 0 deletions src/webview/migrations/0009_profile_notification_email_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.2.9 on 2026-04-22 13:35

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('webview', '0008_alter_suggestionnotification_suggestion'),
]

operations = [
migrations.AddField(
model_name='profile',
name='notification_email',
field=models.EmailField(blank=True, help_text="Email address to use for notifications in case it's different from the GitHub email", max_length=254),
),
migrations.AddField(
model_name='profile',
name='receive_email_notifications',
field=models.BooleanField(default=False, help_text='Receive an email about new notifications'),
),
]
23 changes: 22 additions & 1 deletion src/webview/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from shared.models import TimeStampMixin
from shared.models.linkage import CVEDerivationClusterProposal
from shared.models.nix_evaluation import NixMaintainer


class Notification(TimeStampMixin):
Expand Down Expand Up @@ -92,6 +93,27 @@ class Profile(models.Model):
default=True,
help_text="Automatically subscribe to notifications for packages this user maintains",
)
notification_email = models.EmailField(
blank=True,
help_text="Email address to use for notifications in case it's different from the GitHub email",
)
receive_email_notifications = models.BooleanField(
default=False,
help_text="Receive an email about new notifications",
)

@property
def github_email(self) -> str | None:
try:
maintainer = NixMaintainer.objects.get(
github_id=self.user.socialaccount_set.get(provider="github").uid
)
return maintainer.email
except (
NixMaintainer.DoesNotExist,
self.user.socialaccount_set.model.DoesNotExist,
):
return None

def create_notification(
self, suggestion: CVEDerivationClusterProposal
Expand All @@ -101,7 +123,6 @@ def create_notification(
user=self.user,
suggestion=suggestion,
)

self.unread_notifications_count += 1
self.save(update_fields=["unread_notifications_count"])

Expand Down
12 changes: 12 additions & 0 deletions src/webview/subscriptions/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
AddSubscriptionView,
PackageSubscriptionView,
RemoveSubscriptionView,
SetNotificationEmailView,
SubscriptionCenterView,
ToggleAutoSubscribeView,
ToggleReceiveEmailNotificationsView,
)

app_name = "subscriptions"
Expand All @@ -19,6 +21,16 @@
ToggleAutoSubscribeView.as_view(),
name="toggle_auto_subscribe",
),
path(
"toggle-receive-email-notifications/",
ToggleReceiveEmailNotificationsView.as_view(),
name="toggle_receive_email_notifications",
),
path(
"set-email/",
SetNotificationEmailView.as_view(),
name="set_email",
),
path(
"package/<str:package_name>/", PackageSubscriptionView.as_view(), name="package"
),
Expand Down
Loading