From 892ba3da7b6acb018ac3b96d24573356778d6247 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Sun, 30 Nov 2025 02:04:45 +0530 Subject: [PATCH 01/39] feat: Add newsletter page with new releases (#3725) - Create /newsletter/ page with comprehensive project statistics - Display recent bugs, top contributors, and leaderboards - Add New Releases section showing latest repository releases - Include PR contributors and code review leaderboards - Show active bug hunts with prize information - Display bug categories distribution for the month - Add top streakers section - Responsive design with dark mode support - Uses BLT brand color (#e74c3c) consistently --- blt/urls.py | 2 + website/templates/newsletter.html | 381 ++++++++++++++++++++++++++++++ website/views/newsletter.py | 171 ++++++++++++++ 3 files changed, 554 insertions(+) create mode 100644 website/templates/newsletter.html create mode 100644 website/views/newsletter.py diff --git a/blt/urls.py b/blt/urls.py index f5c8fac3d6..d27e6d38cd 100644 --- a/blt/urls.py +++ b/blt/urls.py @@ -210,6 +210,7 @@ update_content_comment, vote_count, ) +from website.views.newsletter import NewsletterView from website.views.organization import ( BountyPayoutsView, CreateHunt, @@ -621,6 +622,7 @@ EachmonthLeaderboardView.as_view(), name="leaderboard_eachmonth", ), + re_path(r"^newsletter/$", NewsletterView.as_view(), name="newsletter"), re_path( r"^api/v1/issue/like/(?P\w+)/$", LikeIssueApiView.as_view(), diff --git a/website/templates/newsletter.html b/website/templates/newsletter.html new file mode 100644 index 0000000000..80ad6f3edb --- /dev/null +++ b/website/templates/newsletter.html @@ -0,0 +1,381 @@ +{% extends "base.html" %} +{% load static %} +{% load gravatar %} +{% load humanize %} +{% block title %} + Newsletter | OWASP BLT +{% endblock title %} +{% block description %} + Stay updated with the latest bugs, top contributors, new releases, and project statistics from OWASP BLT. +{% endblock description %} +{% block keywords %} + Newsletter, OWASP BLT, Bug Bounty, Security, Top Contributors, Releases, Statistics +{% endblock keywords %} +{% block og_title %} + OWASP BLT Newsletter - Project Updates and Statistics +{% endblock og_title %} +{% block og_description %} + Get the latest updates from OWASP BLT including recent bugs, top contributors, new releases, and project statistics. +{% endblock og_description %} +{% block content %} + {% include "includes/sidenav.html" %} +
+ +
+
+

+ BLT Newsletter +

+

+ Your monthly digest of bugs, contributors, releases, and project statistics +

+
+
+
+ +
+

+ Platform Statistics +

+
+
+
{{ total_bugs|intcomma }}
+
Total Bugs
+
+
+
{{ bugs_this_month|intcomma }}
+
Bugs This Month
+
+
+
{{ total_users|intcomma }}
+
Total Users
+
+
+
{{ active_hunts_count }}
+
Active Bug Hunts
+
+
+
+
+ +
+
+

+ Recent Bugs +

+
+
+ {% if recent_bugs %} + + {% else %} +

+ + No recent bugs found +

+ {% endif %} +
+ +
+ +
+
+

+ Top Contributors +

+
+
+ {% if leaderboard %} +
+ {% for leader in leaderboard %} +
+
+ + {{ forloop.counter }} + + {% if leader.userprofile.avatar %} + {{ leader.username }} + {% else %} + {{ leader.username }} + {% endif %} + + {{ leader.username }} + +
+ + {{ leader.total_score|intcomma }} pts + +
+ {% endfor %} +
+ {% else %} +

+ + No leaderboard data available +

+ {% endif %} +
+ +
+ +
+
+

+ New Releases +

+
+
+ {% if recent_releases %} +
+ {% for release in recent_releases %} +
+
+
+
+ +
+
+ + {{ release.name }} + +

{{ release.release_name }}

+
+
+ + {{ release.release_datetime|timesince }} ago + +
+
+ {% endfor %} +
+ {% else %} +

+ + No recent releases found +

+ {% endif %} +
+
+ +
+
+

+ Top PR Contributors + (Last 6 months) +

+
+
+ {% if pr_leaderboard %} +
+ {% for leader in pr_leaderboard %} +
+
+ + {{ forloop.counter }} + + {% if leader.contributor__avatar_url %} + {{ leader.contributor__name }} + {% else %} +
+ +
+ {% endif %} +
+ {% if leader.user_profile__user__username %} + + {{ leader.user_profile__user__username }} + + {% else %} + {{ leader.contributor__name }} + {% endif %} + {% if leader.contributor__github_url %} + + + + {% endif %} +
+
+ + {{ leader.total_prs }} PRs + +
+ {% endfor %} +
+ {% else %} +

+ + No PR data available +

+ {% endif %} +
+
+
+ +
+

+ Active Bug Hunts +

+ {% if active_hunts %} + + {% else %} +
+ +

No active bug hunts at the moment

+ + View all bounties + +
+ {% endif %} +
+ +
+

+ Bug Categories This Month +

+ {% if bug_categories %} +
+
+ {% for category in bug_categories %} +
+ {{ category.name }} + {{ category.count }} +
+ {% endfor %} +
+
+ {% else %} +
+ +

No bug category data available this month

+
+ {% endif %} +
+ +
+

+ Top Streakers +

+ {% if top_streakers %} + + {% else %} +
+ +

No streak data available

+
+ {% endif %} +
+ +
+

Want to Contribute?

+

+ Join our community of security researchers and developers. Report bugs, earn rewards, and help make the web safer! +

+ +
+
+
+{% endblock content %} diff --git a/website/views/newsletter.py b/website/views/newsletter.py new file mode 100644 index 0000000000..dc5c856f57 --- /dev/null +++ b/website/views/newsletter.py @@ -0,0 +1,171 @@ +""" +Newsletter views for displaying project statistics, leaderboards, and updates. +""" + +import logging +from datetime import timedelta + +from dateutil.relativedelta import relativedelta +from django.db.models import Count, Q, Sum +from django.utils import timezone +from django.views.generic import TemplateView + +from website.models import ( + Contributor, + Domain, + GitHubIssue, + GitHubReview, + Hunt, + Issue, + Organization, + Points, + Project, + Repo, + User, + UserProfile, +) + +logger = logging.getLogger(__name__) + + +class NewsletterView(TemplateView): + """ + Newsletter page displaying project statistics, top contributors, + recent bugs, and releases. + """ + + template_name = "newsletter.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Time ranges for filtering + now = timezone.now() + last_30_days = now - timedelta(days=30) + last_6_months = now - relativedelta(months=6) + + # ===== Summary Statistics ===== + context["total_bugs"] = Issue.objects.count() + context["bugs_this_month"] = Issue.objects.filter(created__gte=last_30_days).count() + context["open_bugs"] = Issue.objects.filter(status="open").count() + context["closed_bugs"] = Issue.objects.filter(status="closed").count() + context["total_users"] = User.objects.count() + context["total_domains"] = Domain.objects.count() + context["total_organizations"] = Organization.objects.count() + context["active_hunts_count"] = Hunt.objects.filter(is_published=True, end_on__gte=now).count() + + # ===== Recent Bugs/Issues ===== + recent_bugs = ( + Issue.objects.select_related("user", "domain") + .filter(is_hidden=False) + .order_by("-created")[:10] + ) + context["recent_bugs"] = recent_bugs + + # ===== Points Leaderboard (Top Contributors) ===== + leaderboard = ( + User.objects.annotate(total_score=Sum("points__score")) + .filter(total_score__gt=0, username__isnull=False) + .exclude(username="") + .order_by("-total_score")[:10] + ) + context["leaderboard"] = leaderboard + + # ===== Monthly Top Contributors ===== + monthly_leaderboard = ( + User.objects.filter(points__created__gte=last_30_days) + .annotate(monthly_score=Sum("points__score")) + .filter(monthly_score__gt=0, username__isnull=False) + .exclude(username="") + .order_by("-monthly_score")[:5] + ) + context["monthly_leaderboard"] = monthly_leaderboard + + # ===== Pull Request Leaderboard ===== + pr_leaderboard = ( + GitHubIssue.objects.filter( + type="pull_request", + is_merged=True, + contributor__isnull=False, + merged_at__gte=last_6_months, + ) + .filter( + Q(repo__repo_url__startswith="https://github.com/OWASP-BLT/") + | Q(repo__repo_url__startswith="https://github.com/owasp-blt/") + ) + .exclude(contributor__name__icontains="copilot") + .select_related("contributor", "user_profile__user") + .values( + "contributor__name", + "contributor__github_url", + "contributor__avatar_url", + "user_profile__user__username", + ) + .annotate(total_prs=Count("id")) + .order_by("-total_prs")[:5] + ) + context["pr_leaderboard"] = pr_leaderboard + + # ===== Code Review Leaderboard ===== + code_review_leaderboard = ( + GitHubReview.objects.filter( + reviewer_contributor__isnull=False, + pull_request__merged_at__gte=last_6_months, + ) + .filter( + Q(pull_request__repo__repo_url__startswith="https://github.com/OWASP-BLT/") + | Q(pull_request__repo__repo_url__startswith="https://github.com/owasp-blt/") + ) + .exclude(reviewer_contributor__name__icontains="copilot") + .values( + "reviewer_contributor__name", + "reviewer_contributor__github_url", + "reviewer_contributor__avatar_url", + ) + .annotate(total_reviews=Count("id")) + .order_by("-total_reviews")[:5] + ) + context["code_review_leaderboard"] = code_review_leaderboard + + # ===== Top Streakers ===== + top_streakers = UserProfile.objects.filter(current_streak__gt=0).order_by("-current_streak")[:5] + context["top_streakers"] = top_streakers + + # ===== New Releases ===== + # Get repos with recent releases, ordered by release date + recent_releases = ( + Repo.objects.filter( + release_name__isnull=False, + release_datetime__isnull=False, + ) + .select_related("project") + .order_by("-release_datetime")[:10] + ) + context["recent_releases"] = recent_releases + + # ===== Active Bug Hunts ===== + active_hunts = ( + Hunt.objects.filter(is_published=True, end_on__gte=now) + .select_related("domain") + .order_by("end_on")[:5] + ) + context["active_hunts"] = active_hunts + + # ===== Recent Projects ===== + recent_projects = Project.objects.order_by("-created")[:5] + context["recent_projects"] = recent_projects + + # ===== Bug Categories Distribution ===== + bug_categories = ( + Issue.objects.filter(created__gte=last_30_days) + .values("label") + .annotate(count=Count("id")) + .order_by("-count") + ) + # Map label numbers to names + label_map = dict(Issue.labels) + context["bug_categories"] = [ + {"name": label_map.get(item["label"], "Unknown"), "count": item["count"]} for item in bug_categories + ] + + return context From c03abc0a3ada476e7c61f6de0cf07b0f9e96bc22 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Sun, 30 Nov 2025 02:30:32 +0530 Subject: [PATCH 02/39] Fix bot exclusions, add code reviewers section, and use timeuntil filter --- website/templates/newsletter.html | 75 ++++++++++++++++++++++++--- website/views/newsletter.py | 84 ++++++++++++------------------- 2 files changed, 99 insertions(+), 60 deletions(-) diff --git a/website/templates/newsletter.html b/website/templates/newsletter.html index 80ad6f3edb..60e4d0e1e7 100644 --- a/website/templates/newsletter.html +++ b/website/templates/newsletter.html @@ -72,9 +72,7 @@

class="block p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border border-gray-100 dark:border-gray-700">
-

- {{ bug.description|truncatechars:60 }} -

+

{{ bug.description|truncatechars:60 }}

{{ bug.domain_name|default:"Unknown" }} @@ -121,10 +119,14 @@

{% if leader.userprofile.avatar %} {{ leader.username }} {% else %} {{ leader.username }} {% endif %}

{{ release.release_name }}

- - {{ release.release_datetime|timesince }} ago - + {{ release.release_datetime|timesince }} ago {% endfor %} @@ -213,6 +213,8 @@

{% if leader.contributor__avatar_url %} {{ leader.contributor__name }} {% else %}
@@ -251,6 +253,59 @@

{% endif %}

+ +
@@ -266,6 +321,8 @@
diff --git a/website/templates/newsletter/detail.html b/website/templates/newsletter/detail.html new file mode 100644 index 0000000000..b68a5f44c9 --- /dev/null +++ b/website/templates/newsletter/detail.html @@ -0,0 +1,241 @@ +{% extends "base.html" %} +{% load static %} +{% load custom_tags %} +{% load gravatar %} +{% load custom_filters %} +{% block title %}{{ newsletter.title }} - Newsletter{% endblock %} +{% block description %}{{ newsletter.content|striptags|truncatechars:160 }}{% endblock %} +{% block content %} + + {% include "includes/sidenav.html" %} +
+
+ + +
+ +
+
+ {% if newsletter.featured_image %} + {{ newsletter.title }} + {% endif %} +
+

{{ newsletter.title }}

+
+ Published on {{ newsletter.published_at|date:"F j, Y" }} + + {{ newsletter.view_count }} views +
+
{{ newsletter.content|markdown_filter }}
+ + {% if newsletter.recent_bugs_section and recent_bugs %} +
+

Recent Security Findings

+
+ +
+
+ {% endif %} + {% if newsletter.leaderboard_section and leaderboard %} +
+

Leaderboard Updates

+
+
    + {% for user in leaderboard %} +
  • +
    + + {{ forloop.counter }} + + + {{ user.username }} + +
    + {{ user.score }} points +
  • + {% endfor %} +
+
+
+ {% endif %} + {% if newsletter.reported_ips_section and reported_ips %} +
+

Recently Reported IPs

+
+
    + {% for report in reported_ips %} +
  • + + + + + + +
    +

    {{ report.ip_address }} ({{ report.get_ip_type_display }})

    +

    {{ report.activity_title }}

    +
    +
  • + {% endfor %} +
+
+
+ {% endif %} + +
+
+

Subscribe to Our Newsletter

+

Get the latest security news and updates directly in your inbox.

+ + Subscribe Now + +
+
+
+
+
+ +
+ +
+
+

Recent Newsletters

+
+
+
    + {% for item in recent_newsletters %} +
  • + {{ item.title }} +

    {{ item.published_at|date:"M j, Y" }}

    +
  • + {% endfor %} +
+
+
+ +
+
+

Newsletter Options

+
+
+ +
+
+
+
+
+
+{% endblock %} diff --git a/website/templates/newsletter/email/confirmation_email.html b/website/templates/newsletter/email/confirmation_email.html new file mode 100644 index 0000000000..09d594cc3e --- /dev/null +++ b/website/templates/newsletter/email/confirmation_email.html @@ -0,0 +1,273 @@ + + + + + + + + + Codestin Search App + + + + + + + + + + + + diff --git a/website/templates/newsletter/email/newsletter_email.html b/website/templates/newsletter/email/newsletter_email.html new file mode 100644 index 0000000000..18c6802713 --- /dev/null +++ b/website/templates/newsletter/email/newsletter_email.html @@ -0,0 +1,261 @@ + + + + + + + + + Codestin Search App + + + + + + + + + + + + diff --git a/website/templates/newsletter/home.html b/website/templates/newsletter/home.html new file mode 100644 index 0000000000..5c35f2ba5b --- /dev/null +++ b/website/templates/newsletter/home.html @@ -0,0 +1,193 @@ +{% extends "base.html" %} +{% load static %} +{% load custom_tags %} +{% load gravatar %} +{% load custom_filters %} +{% block title %}Newsletters - Bug Logging Tool{% endblock %} +{% block description %} + Stay updated with the latest security news, bug reports, and leaderboard updates from OWASP BLT. +{% endblock %} +{% block content %} + + {% include "includes/sidenav.html" %} +
+
+
+ +
+

Newsletters

+ + {% if messages %} +
+ {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + + {% if featured_newsletter %} +
+ {% if featured_newsletter.featured_image %} +
+ {{ featured_newsletter.title }} +
+ {% endif %} +
+
+ + + + + + {{ featured_newsletter.published_at|date:"F j, Y" }} + + + + + + + + {{ featured_newsletter.view_count }} views + +
+

{{ featured_newsletter.title }}

+
+ {{ featured_newsletter.content|markdown_filter|truncatewords_html:50 }} +
+ Read more → +
+
+ {% endif %} + +
+

All Newsletters

+ {% if newsletters %} + + + {% if newsletters.has_other_pages %} +
+
+ {% if newsletters.has_previous %} + + Previous + + {% endif %} + {% if newsletters.has_next %} + + Next + + {% endif %} +
+
+ {% endif %} + {% else %} +
+

No newsletters available yet.

+
+ {% endif %} +
+
+ +
+
+

Subscribe to Newsletter

+

Stay updated with our latest security news and bug reports.

+ {% if user.is_authenticated and newsletter_subscription.subscribed %} + {% if newsletter_subscription.is_active %} + {% if newsletter_subscription.confirmed %} +
+

You're subscribed to our newsletter!

+
+ + Manage Preferences + + {% else %} +
+

Please confirm your subscription by checking your email.

+
+ {% endif %} + {% else %} + + Subscribe Now + + {% endif %} + {% else %} + + Subscribe Now + + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/website/templates/newsletter/preferences.html b/website/templates/newsletter/preferences.html new file mode 100644 index 0000000000..7884041d82 --- /dev/null +++ b/website/templates/newsletter/preferences.html @@ -0,0 +1,204 @@ +{% extends "base.html" %} +{% load static %} +{% load custom_tags %} +{% block title %}Newsletter Preferences - Bug Logging Tool{% endblock %} +{% block description %}Manage your newsletter subscription preferences for OWASP BLT.{% endblock %} +{% block content %} + + {% include "includes/sidenav.html" %} +
+
+ + {% if messages %} +
+ {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} +
+
+
+

Newsletter Preferences

+
+
+
+ {{ subscriber.subscription_status }} +
+
+ {% if not subscriber.confirmed and subscriber.is_active %} +
+
+
+ + + +
+
+

+ Your email address has not been confirmed yet. Please check your inbox for a confirmation email. + Resend confirmation email +

+
+
+
+ {% endif %} +
+ {% csrf_token %} +
+ +
+ +
+
+
+ Content Preferences +
+
+
+ +
+
+ +

Receive updates about recently reported security issues

+
+
+
+
+ +
+
+ +

Get notifications about changes in the community leaderboard

+
+
+
+
+ +
+
+ +

Stay informed about important security news and analysis

+
+
+
+
+
+ + + Unsubscribe + +
+
+
+
+ + {% if recent_newsletters %} +
+

Recent Newsletters

+
+ +
+
+ {% endif %} + +
+
+ +{% endblock %} diff --git a/website/templates/newsletter/subscribe.html b/website/templates/newsletter/subscribe.html new file mode 100644 index 0000000000..c184335d09 --- /dev/null +++ b/website/templates/newsletter/subscribe.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} +{% load static %} +{% load custom_tags %} +{% block title %}Subscribe to Newsletter - Bug Logging Tool{% endblock %} +{% block description %} + Subscribe to receive the latest security news, bug reports, and leaderboard updates from OWASP BLT. +{% endblock %} +{% block content %} + + {% include "includes/sidenav.html" %} +
+
+
+
+
+ + + +

Subscribe to Our Newsletter

+

Stay updated with the latest security findings and community achievements

+
+
+
+ {% csrf_token %} +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+ +

+ We'll handle your data according to our privacy policy and will never share it with third parties. +

+
+
+
+ +
+
+
+
+
+
+
+
+
+ Newsletter benefits +
+
+
+
+
+
+ +
+
+

Latest Bug Reports

+
+

Stay informed about the latest security issues and vulnerabilities discovered in the wild.

+
+
+
+
+
+
+
+ +
+
+

Community Updates

+
+

Get updates on leaderboard changes, new features, and upcoming events in our community.

+
+
+
+
+
+
+
+ +
+
+

Expert Security Insights

+
+

Gain insights from security experts and learn about best practices to protect your systems.

+
+
+
+
+
+
+ {% if request.user.is_authenticated %} +
+

+ Already have an account and want to manage your subscription preferences? + Manage your newsletter preferences +

+
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/website/templatetags/custom_filters.py b/website/templatetags/custom_filters.py index b7db81f77a..9bbbe6c9cf 100644 --- a/website/templatetags/custom_filters.py +++ b/website/templatetags/custom_filters.py @@ -1,8 +1,10 @@ # avoid using custom filters if possible import json +import markdown from django import template from django.core.serializers.json import DjangoJSONEncoder +from django.template.defaultfilters import stringfilter from django.utils.safestring import mark_safe register = template.Library() @@ -23,3 +25,10 @@ def before_dot(value): def to_json(value): """Convert Python object to JSON string""" return mark_safe(json.dumps(value, cls=DjangoJSONEncoder)) + + +@register.filter +@stringfilter +def markdown_filter(value): + """Converts markdown text to HTML.""" + return mark_safe(markdown.markdown(value, extensions=["extra", "nl2br", "sane_lists"])) diff --git a/website/views/core.py b/website/views/core.py index b6bcc2cd90..d4f727f5d0 100644 --- a/website/views/core.py +++ b/website/views/core.py @@ -60,6 +60,8 @@ InviteOrganization, Issue, ManagementCommandLog, + Newsletter, + NewsletterSubscriber, Organization, Points, PRAnalysisReport, @@ -3272,3 +3274,48 @@ def set_theme(request): return JsonResponse({"status": "error", "message": "An internal error occurred."}, status=400) return JsonResponse({"status": "error", "message": "Invalid request method"}, status=400) + + +# Add the newsletter context processor function at the end of the file +def newsletter_context_processor(request): + """ + Adds newsletter subscription data to the template context + """ + import logging + + logger = logging.getLogger(__name__) + + context = {} + + if request.user.is_authenticated: + try: + # Use filter() instead of get() and order by most recent + subscribers = NewsletterSubscriber.objects.filter(user=request.user) + + # Log how many subscribers were found for debugging + if subscribers.count() > 1: + logger.warning( + f"Multiple newsletter subscriptions found for user {request.user.username} (ID: {request.user.id}). " + f"Count: {subscribers.count()}" + ) + + subscriber = subscribers.order_by("-subscribed_at").first() + if subscriber: + context["newsletter_subscription"] = { + "subscribed": True, + "confirmed": subscriber.confirmed, + "is_active": subscriber.is_active, + } + else: + context["newsletter_subscription"] = {"subscribed": False} + except Exception as e: + logger.error(f"Error in newsletter context processor for user {request.user.id}: {str(e)}") + context["newsletter_subscription"] = {"subscribed": False} + + try: + context["latest_newsletter"] = Newsletter.objects.filter(status="published").order_by("-published_at").first() + except Exception as e: + logger.error(f"Error fetching latest newsletter: {str(e)}") + pass + + return context diff --git a/website/views/user.py b/website/views/user.py index dba9dff13c..d0d3f77b80 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -1,6 +1,10 @@ import json import logging import os +import re +import smtplib +import time +import uuid from datetime import datetime, timezone from allauth.account.signals import user_signed_up @@ -11,9 +15,13 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache +from django.core.exceptions import ValidationError from django.core.mail import send_mail +from django.core.paginator import Paginator from django.db.models import Count, F, Q, Sum from django.db.models.functions import ExtractMonth from django.dispatch import receiver @@ -24,7 +32,7 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods +from django.views.decorators.http import require_http_methods, require_POST from django.views.generic import DetailView, ListView, TemplateView, View from rest_framework.authtoken.models import Token from rest_framework.authtoken.views import ObtainAuthToken @@ -34,6 +42,7 @@ from website.forms import MonitorForm, UserDeleteForm, UserProfileForm from website.models import ( IP, + Activity, BaconEarning, BaconSubmission, Badge, @@ -48,6 +57,8 @@ Issue, IssueScreenshot, Monitor, + Newsletter, + NewsletterSubscriber, Notification, Points, Repo, @@ -58,6 +69,7 @@ UserProfile, Wallet, ) +from website.utils import get_client_ip logger = logging.getLogger(__name__) @@ -1667,3 +1679,451 @@ def delete_notification(request, notification_id): ) else: return JsonResponse({"status": "error", "message": "Invalid request method"}, status=405) + try: + notification = get_object_or_404(Notification, id=notification_id, user=request.user) + + notification.is_deleted = True + notification.save() + + return JsonResponse({"status": "success", "message": "Notification deleted successfully"}) + except Exception as e: + logger.error(f"Error deleting notification: {e}") + return JsonResponse( + {"status": "error", "message": "An error occured while deleting notification, please try again."}, + status=400, + ) + else: + return JsonResponse({"status": "error", "message": "Invalid request method"}, status=405) + + +def newsletter_home(request): + """View for displaying list of published newsletters""" + newsletters = Newsletter.objects.filter(status="published").order_by("-published_at") + + paginator = Paginator(newsletters, 10) + page = request.GET.get("page") + newsletters = paginator.get_page(page) + + featured_newsletter = Newsletter.objects.filter(status="published").order_by("-published_at").first() + + if request.user.is_authenticated: + Activity.objects.create( + user=request.user, + action_type="view", + title="Viewed newsletters", + content_type=ContentType.objects.get_for_model(Newsletter), + object_id=featured_newsletter.id if featured_newsletter else None, + ) + + return render( + request, + "newsletter/home.html", + { + "newsletters": newsletters, + "featured_newsletter": featured_newsletter, + }, + ) + + +def newsletter_detail(request, slug): + """View for displaying a specific newsletter""" + newsletter = get_object_or_404(Newsletter, slug=slug, status="published") + + newsletter.increment_view_count() + + recent_newsletters = ( + Newsletter.objects.filter(status="published").exclude(id=newsletter.id).order_by("-published_at")[:5] + ) + + if request.user.is_authenticated: + Activity.objects.create( + user=request.user, + action_type="view", + title=f"Read newsletter: {newsletter.title}", + content_type=ContentType.objects.get_for_model(Newsletter), + object_id=newsletter.id, + ) + + context = { + "newsletter": newsletter, + "recent_newsletters": recent_newsletters, + } + + if newsletter.recent_bugs_section: + context["recent_bugs"] = newsletter.get_recent_bugs() + + if newsletter.leaderboard_section: + context["leaderboard"] = newsletter.get_leaderboard_updates() + + # Add reported IPs only if the newsletter includes this section AND the user is authenticated + # This protects sensitive IP data from public view + if newsletter.reported_ips_section and request.user.is_authenticated: + if request.user.is_staff: + context["reported_ips"] = newsletter.get_reported_ips() + else: + limited_ips = [] + for ip in newsletter.get_reported_ips(): + ip_parts = ip.address.split(".") + if len(ip_parts) == 4: # IPv4 + masked_ip = f"{ip_parts[0]}.{ip_parts[1]}.*.*" + else: + masked_ip = "Redacted IP" + + limited_ips.append({"address": masked_ip, "created": ip.created, "count": ip.count}) + context["reported_ips"] = limited_ips + + return render(request, "newsletter/detail.html", context) + + +def newsletter_subscribe(request): + """View for newsletter subscription""" + if request.method == "POST": + client_ip = get_client_ip(request) + + rate_key = f"newsletter_subscribe_rate_{client_ip}" + rate_attempts = cache.get(rate_key, 0) + + # Limit to 5 subscription attempts per hour + if rate_attempts >= 5: + logger.warning(f"Rate limit exceeded for newsletter subscription from IP: {client_ip}") + messages.error(request, "Too many subscription attempts. Please try again later.") + return redirect("newsletter_subscribe") + + email = request.POST.get("email", "").strip().lower() + name = request.POST.get("name", "").strip() + + # Sanitize to prevent XSS + if name: + name = re.sub(r'[<>"\']', "", name) + name = name[:100] + + if not email: + messages.error(request, "Email address is required.") + return redirect("newsletter_subscribe") + + if not re.match(r"[^@]+@[^@]+\.[^@]+", email): + messages.error(request, "Please enter a valid email address.") + return redirect("newsletter_subscribe") + + cache.set(rate_key, rate_attempts + 1, 3600) + + try: + # Update any existing active subscriptions for this email to inactive + NewsletterSubscriber.objects.filter(email=email).exclude(is_active=False).update(is_active=False) + + subscriber, created = NewsletterSubscriber.objects.get_or_create( + email=email, + defaults={ + "name": name, + "user": request.user if request.user.is_authenticated else None, + "is_active": True, + "confirmed": False, + "token_created_at": timezone.now(), + }, + ) + + if not created: + subscriber.is_active = True + subscriber.name = name if name else subscriber.name + subscriber.confirmation_token = uuid.uuid4() + subscriber.token_created_at = timezone.now() + subscriber.save(update_fields=["is_active", "name", "confirmation_token", "token_created_at"]) + + last_sent_key = f"last_subscribe_email_{email}" + last_sent = cache.get(last_sent_key) + + if last_sent and not created: + time_since_last = time.time() - last_sent + if time_since_last < 300: + messages.warning( + request, + "You've recently requested a confirmation email. Please check your inbox or spam folder.", + ) + return redirect("newsletter_home") + + cache.set(last_sent_key, time.time(), 3600) + + send_confirmation_email(subscriber) + + if created: + logger.info(f"New subscription created for {email}") + messages.success( + request, "Thanks for subscribing! Please check your email to confirm your subscription." + ) + else: + logger.info(f"Subscription reactivated for {email}") + messages.success(request, "Your subscription has been reactivated. Please confirm your email.") + + except ValidationError as e: + logger.error(f"Validation error in newsletter subscription: {str(e)}") + messages.error(request, f"There was an error processing your subscription: {str(e)}") + except ConnectionError as e: + logger.error(f"Connection error sending confirmation email: {str(e)}") + messages.error(request, "We couldn't send a confirmation email right now. Please try again later.") + except Exception as e: + error_id = uuid.uuid4() + logger.error(f"Error ID: {error_id} - Error in newsletter subscription: {str(e)}", exc_info=True) + messages.error( + request, + f"There was an error processing your subscription (Error ID: {error_id}). Please try again later.", + ) + + return redirect("newsletter_home") + + return render(request, "newsletter/subscribe.html") + + +def send_confirmation_email(subscriber): + """Send confirmation email to new subscribers""" + # Always use HTTPS in production + scheme = "https" if not settings.DEBUG else "http" + + # Use site framework or ALLOWED_HOSTS for better domain management + try: + domain = Site.objects.get_current().domain + except: + domain = settings.DOMAIN_NAME + + if settings.DEBUG: + domain = "localhost:8000" + logger.info(f"Using development domain: {domain}") + + if not subscriber.token_created_at: + subscriber.token_created_at = timezone.now() + subscriber.save() + + token_path = reverse("newsletter_confirm", args=[subscriber.confirmation_token]) + confirm_url = f"{scheme}://{domain}{token_path}" + + logger.info(f"Generated confirmation URL: {confirm_url}") + + context = {"name": subscriber.name or "there", "confirm_url": confirm_url, "project_name": settings.PROJECT_NAME} + + subject = f"Confirm your {settings.PROJECT_NAME} newsletter subscription" + html_message = render_to_string("newsletter/email/confirmation_email.html", context) + + try: + send_mail( + subject=subject, + message=f"Please confirm your subscription: {confirm_url}", + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[subscriber.email], + html_message=html_message, + fail_silently=False, + ) + logger.info(f"Confirmation email sent to: {subscriber.email}") + except ConnectionRefusedError as e: + logger.error(f"Email server connection refused when sending to {subscriber.email}: {str(e)}") + raise RuntimeError("Could not connect to email server") + except smtplib.SMTPException as e: + logger.error(f"SMTP error when sending to {subscriber.email}: {str(e)}") + raise RuntimeError("Email server rejected the message") + except Exception as e: + logger.error(f"Failed to send confirmation email to {subscriber.email}: {str(e)}") + raise + + +def newsletter_confirm(request, token): + """View to confirm newsletter subscription""" + try: + logger.info(f"Newsletter confirmation attempt with token: {token}") + + subscriber = get_object_or_404(NewsletterSubscriber, confirmation_token=token) + logger.info(f"Found subscriber with email: {subscriber.email}") + + try: + if subscriber.is_token_expired(): + subscriber.refresh_token() + send_confirmation_email(subscriber) + messages.warning( + request, "Your confirmation link has expired. We've sent a new confirmation email to your address." + ) + return redirect("newsletter_home") + except AttributeError as e: + # This would happen if is_token_expired method doesn't exist or implementation changed + logger.error(f"Token expiration check failed due to AttributeError: {str(e)}") + messages.error(request, "There was a system error processing your request. Our team has been notified.") + return redirect("newsletter_home") + + if not subscriber.confirmed: + subscriber.confirmed = True + subscriber.save(update_fields=["confirmed"]) + messages.success(request, "Thank you! Your newsletter subscription has been confirmed.") + + logger.info(f"Successfully confirmed subscription for: {subscriber.email}") + else: + messages.info(request, "Your subscription is already confirmed.") + + return redirect("newsletter_home") + except NewsletterSubscriber.DoesNotExist: + logger.warning(f"Invalid confirmation token used: {token}") + messages.error(request, "The confirmation link is invalid or has already been used.") + return redirect("newsletter_home") + except Exception as e: + error_id = uuid.uuid4() + logger.error(f"Error ID: {error_id} - Unexpected error in newsletter confirmation: {str(e)}", exc_info=True) + messages.error( + request, + f"There was an unexpected error confirming your subscription (Error ID: {error_id}). " + "Please try again or contact support with this Error ID.", + ) + return redirect("newsletter_home") + + +def newsletter_unsubscribe(request, token): + """View to unsubscribe from newsletter""" + try: + client_ip = get_client_ip(request) + cache_key = f"unsubscribe_attempts_{client_ip}" + attempts = cache.get(cache_key, 0) + + # Limit to 10 unsubscribe attempts per hour from the same IP + if attempts >= 10: + logger.warning(f"Unsubscribe rate limit exceeded from IP: {client_ip}") + messages.error(request, "Too many unsubscribe attempts. Please try again later.") + return redirect("home") + + cache.set(cache_key, attempts + 1, 3600) + + subscriber = get_object_or_404(NewsletterSubscriber, confirmation_token=token) + + # Add token creation time validation for extra security + if subscriber.token_created_at: + max_token_age = timezone.now() - timezone.timedelta(days=90) + if subscriber.token_created_at < max_token_age: + # Token too old - refresh it and show warning + subscriber.refresh_token() + logger.warning(f"Unsubscribe attempted with expired token for: {subscriber.email}") + messages.warning( + request, + "This unsubscribe link has expired. We've sent a new confirmation email with an updated link.", + ) + send_confirmation_email(subscriber) + return redirect("home") + + if subscriber.is_active: + unsubscribe_time = timezone.now() + + # Update subscriber status + subscriber.is_active = False + subscriber.save(update_fields=["is_active"]) + + messages.success(request, "You have been unsubscribed from the newsletter.") + + logger.info(f"User unsubscribed from newsletter: {subscriber.email} at {unsubscribe_time}") + + # Create activity record if user is authenticated + if subscriber.user and hasattr(subscriber.user, "id"): + Activity.objects.create( + user=subscriber.user, + action_type="update", + title="Unsubscribed from newsletter", + description=f"User unsubscribed from the newsletter at {unsubscribe_time}", + content_type=ContentType.objects.get_for_model(NewsletterSubscriber), + object_id=subscriber.id, + ) + else: + messages.info(request, "You are already unsubscribed.") + + return redirect("home") + except NewsletterSubscriber.DoesNotExist: + logger.warning(f"Invalid token used for unsubscribe: {token}") + messages.error(request, "Invalid unsubscribe link. Please contact support if you need assistance.") + return redirect("home") + except Exception as e: + error_id = uuid.uuid4() + logger.error(f"Error ID: {error_id} - Error in newsletter unsubscribe: {str(e)}", exc_info=True) + messages.error( + request, f"An error occurred while processing your request (Error ID: {error_id}). Please try again later." + ) + return redirect("home") + + +@login_required +def newsletter_preferences(request): + """View to update newsletter preferences""" + try: + subscriber = NewsletterSubscriber.objects.get(user=request.user) + except NewsletterSubscriber.DoesNotExist: + # If no subscription exists but user wants to manage preferences, + # create one with their email from their user account + subscriber = NewsletterSubscriber( + user=request.user, + email=request.user.email, + name=request.user.get_full_name(), + confirmed=True, + ) + subscriber.save() + + if request.method == "POST": + subscriber.wants_bug_reports = "wants_bug_reports" in request.POST + subscriber.wants_leaderboard_updates = "wants_leaderboard_updates" in request.POST + subscriber.wants_security_news = "wants_security_news" in request.POST + subscriber.save() + + messages.success(request, "Your newsletter preferences have been updated.") + return redirect("newsletter_preferences") + + recent_newsletters = Newsletter.objects.filter(status="published").order_by("-published_at")[:3] + + return render( + request, "newsletter/preferences.html", {"subscriber": subscriber, "recent_newsletters": recent_newsletters} + ) + + +@require_POST +def newsletter_resend_confirmation(request): + """AJAX view to resend newsletter confirmation email""" + try: + # Rate limiting check + client_ip = get_client_ip(request) + cache_key = f"resend_confirm_rate_{client_ip}" + attempts = cache.get(cache_key, 0) + + if attempts >= 3: + logger.warning(f"Rate limit exceeded for confirmation resend from IP: {client_ip}") + return JsonResponse({"success": False, "error": "Too many attempts. Please try again later."}, status=429) + + data = json.loads(request.body) + email = data.get("email") + + if not email or not re.match(r"[^@]+@[^@]+\.[^@]+", email): + return JsonResponse({"success": False, "error": "Valid email address is required"}) + + # Use the same error message and timing regardless of whether the email exists + # to prevent email enumeration attacks + start_time = time.time() + + try: + subscriber = NewsletterSubscriber.objects.get(email=email, is_active=True, confirmed=False) + + last_sent_key = f"last_confirm_email_{email}" + last_sent = cache.get(last_sent_key) + + if last_sent: + time_since_last = time.time() - last_sent + if time_since_last < 300: + time.sleep(max(0, min(2, 2 - (time.time() - start_time)))) + return JsonResponse( + {"success": False, "error": "Please wait at least 5 minutes before requesting another email."} + ) + + # Record this attempt + cache.set(cache_key, attempts + 1, 1800) + cache.set(last_sent_key, time.time(), 3600) + + send_confirmation_email(subscriber) + + except NewsletterSubscriber.DoesNotExist: + # Sleep to maintain consistent timing even if email doesn't exist + # This prevents timing attacks + time.sleep(max(0, min(2, 2 - (time.time() - start_time)))) + logger.info(f"Confirmation resend attempted for non-existent subscription: {email}") + return JsonResponse({"success": True}) # Return success even if email doesn't exist + + return JsonResponse({"success": True}) + except Exception as e: + logger.error(f"Error in newsletter_resend_confirmation: {str(e)}") + return JsonResponse( + {"success": False, "error": "An error occurred while processing your request. Please try again later."} + ) From f38b6c3157bee69f6d2db9b49c5b19be511a8d91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Nov 2025 19:24:35 +0000 Subject: [PATCH 21/39] Fix syntax error in user.py - remove duplicate code Co-authored-by: DonnieBLT <128622481+DonnieBLT@users.noreply.github.com> --- website/views/user.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/website/views/user.py b/website/views/user.py index d0d3f77b80..54de7a94f3 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -1679,21 +1679,6 @@ def delete_notification(request, notification_id): ) else: return JsonResponse({"status": "error", "message": "Invalid request method"}, status=405) - try: - notification = get_object_or_404(Notification, id=notification_id, user=request.user) - - notification.is_deleted = True - notification.save() - - return JsonResponse({"status": "success", "message": "Notification deleted successfully"}) - except Exception as e: - logger.error(f"Error deleting notification: {e}") - return JsonResponse( - {"status": "error", "message": "An error occured while deleting notification, please try again."}, - status=400, - ) - else: - return JsonResponse({"status": "error", "message": "Invalid request method"}, status=405) def newsletter_home(request): From 96efa192588edde2c110071843394cb101a0ee7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 04:40:33 +0000 Subject: [PATCH 22/39] Squash newsletter migrations into single file 0252 Co-authored-by: DonnieBLT <128622481+DonnieBLT@users.noreply.github.com> --- ...4_newslettersubscriber_token_created_at.py | 20 ------------------- ...> 0252_newsletter_newslettersubscriber.py} | 8 ++++++-- 2 files changed, 6 insertions(+), 22 deletions(-) delete mode 100644 website/migrations/0234_newslettersubscriber_token_created_at.py rename website/migrations/{0233_newsletter_newslettersubscriber.py => 0252_newsletter_newslettersubscriber.py} (91%) diff --git a/website/migrations/0234_newslettersubscriber_token_created_at.py b/website/migrations/0234_newslettersubscriber_token_created_at.py deleted file mode 100644 index d23c9bae1c..0000000000 --- a/website/migrations/0234_newslettersubscriber_token_created_at.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.7 on 2025-03-16 03:25 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("website", "0233_newsletter_newslettersubscriber"), - ] - - operations = [ - migrations.AddField( - model_name="newslettersubscriber", - name="token_created_at", - field=models.DateTimeField( - default=django.utils.timezone.now, help_text="Timestamp when confirmation token was created" - ), - ), - ] diff --git a/website/migrations/0233_newsletter_newslettersubscriber.py b/website/migrations/0252_newsletter_newslettersubscriber.py similarity index 91% rename from website/migrations/0233_newsletter_newslettersubscriber.py rename to website/migrations/0252_newsletter_newslettersubscriber.py index e22617afea..21f801166a 100644 --- a/website/migrations/0233_newsletter_newslettersubscriber.py +++ b/website/migrations/0252_newsletter_newslettersubscriber.py @@ -1,8 +1,9 @@ -# Generated by Django 5.1.6 on 2025-03-13 09:57 +# Generated by Django 5.1.8 on 2025-11-23 04:38 import uuid import django.db.models.deletion +import django.utils.timezone import mdeditor.fields from django.conf import settings from django.db import migrations, models @@ -10,7 +11,7 @@ class Migration(migrations.Migration): dependencies = [ - ("website", "0232_bannedapp"), + ("website", "0251_add_fields_to_management_command_log"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -56,6 +57,9 @@ class Migration(migrations.Migration): ("is_active", models.BooleanField(default=True)), ("confirmation_token", models.UUIDField(default=uuid.uuid4, editable=False)), ("confirmed", models.BooleanField(default=False)), + ("token_created_at", models.DateTimeField( + default=django.utils.timezone.now, help_text="Timestamp when confirmation token was created" + )), ("wants_bug_reports", models.BooleanField(default=True)), ("wants_leaderboard_updates", models.BooleanField(default=True)), ("wants_security_news", models.BooleanField(default=True)), From b626e70ad5c732eaa6f018d246f5c496b87d04cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:47:48 +0000 Subject: [PATCH 23/39] Fix XSS vulnerabilities in newsletter feature by sanitizing markdown HTML output Co-authored-by: DonnieBLT <128622481+DonnieBLT@users.noreply.github.com> --- .../management/commands/send_newsletter.py | 4 +++ website/models.py | 31 ++++++++++++++-- .../newsletter/email/newsletter_email.html | 2 +- website/templatetags/custom_filters.py | 36 +++++++++++++++++-- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/website/management/commands/send_newsletter.py b/website/management/commands/send_newsletter.py index 54f40f62c2..12957362cc 100644 --- a/website/management/commands/send_newsletter.py +++ b/website/management/commands/send_newsletter.py @@ -91,9 +91,13 @@ def send_to_subscriber(self, email, newsletter, subscriber=None, is_test=False): # Build URL scheme based on settings scheme = "https" if not settings.DEBUG else "http" + # Format newsletter content for email (converts markdown to sanitized HTML) + formatted_content = newsletter.format_for_email() + # Newsletter context context = { "newsletter": newsletter, + "newsletter_html_content": formatted_content["content"], "subscriber": subscriber, "unsubscribe_url": f"{scheme}://{settings.DOMAIN_NAME}" + reverse("newsletter_unsubscribe", args=[subscriber.confirmation_token]) diff --git a/website/models.py b/website/models.py index 98b3036218..ccb1f753fe 100644 --- a/website/models.py +++ b/website/models.py @@ -3548,16 +3548,43 @@ def mark_as_sent(self): self.save(update_fields=["email_sent", "email_sent_at"]) def format_for_email(self): - """Format newsletter content for sending via email, converting Markdown to HTML.""" + """Format newsletter content for sending via email, converting Markdown to HTML with XSS protection.""" + import bleach import markdown + # Allowed tags and attributes for email HTML + allowed_tags = [ + 'p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'blockquote', + 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'table', + 'thead', 'tbody', 'tr', 'th', 'td', 'img', 'div', 'span' + ] + + allowed_attributes = { + 'a': ['href', 'title', 'rel'], + 'img': ['src', 'alt', 'title', 'width', 'height'], + 'code': ['class'], + 'div': ['class'], + 'span': ['class'], + 'th': ['align'], + 'td': ['align'], + } + # Convert Markdown content to HTML html_content = markdown.markdown(self.content, extensions=["extra", "codehilite"]) + + # Sanitize HTML to prevent XSS + sanitized_content = bleach.clean( + html_content, + tags=allowed_tags, + attributes=allowed_attributes, + protocols=['http', 'https', 'mailto'], + strip=True + ) # Return formatted content for email return { "title": self.title, - "content": html_content, + "content": sanitized_content, "published_at": self.published_at, } diff --git a/website/templates/newsletter/email/newsletter_email.html b/website/templates/newsletter/email/newsletter_email.html index 18c6802713..c66fe942f7 100644 --- a/website/templates/newsletter/email/newsletter_email.html +++ b/website/templates/newsletter/email/newsletter_email.html @@ -143,7 +143,7 @@ margin-bottom: 30px; font-size: 35px; text-align: center">{{ newsletter.title }}

-
{{ newsletter.content|safe }}
+
{{ newsletter_html_content|safe }}
{% if recent_bugs %}

Date: Thu, 27 Nov 2025 00:31:45 +0000 Subject: [PATCH 24/39] Add dark mode support to newsletter templates and fix pre-commit issues Co-authored-by: DonnieBLT <128622481+DonnieBLT@users.noreply.github.com> --- .../management/commands/send_newsletter.py | 2 +- .../0252_newsletter_newslettersubscriber.py | 9 ++- website/models.py | 53 ++++++++++---- website/templates/newsletter/detail.html | 72 ++++++++++--------- .../newsletter/email/confirmation_email.html | 1 + .../newsletter/email/newsletter_email.html | 1 + website/templates/newsletter/home.html | 46 ++++++------ website/templates/newsletter/preferences.html | 61 +++++++++------- website/templates/newsletter/subscribe.html | 58 ++++++++------- website/templatetags/custom_filters.py | 57 ++++++++++----- 10 files changed, 214 insertions(+), 146 deletions(-) diff --git a/website/management/commands/send_newsletter.py b/website/management/commands/send_newsletter.py index 12957362cc..d9269a2c6c 100644 --- a/website/management/commands/send_newsletter.py +++ b/website/management/commands/send_newsletter.py @@ -93,7 +93,7 @@ def send_to_subscriber(self, email, newsletter, subscriber=None, is_test=False): # Format newsletter content for email (converts markdown to sanitized HTML) formatted_content = newsletter.format_for_email() - + # Newsletter context context = { "newsletter": newsletter, diff --git a/website/migrations/0252_newsletter_newslettersubscriber.py b/website/migrations/0252_newsletter_newslettersubscriber.py index 21f801166a..b85ebd84db 100644 --- a/website/migrations/0252_newsletter_newslettersubscriber.py +++ b/website/migrations/0252_newsletter_newslettersubscriber.py @@ -57,9 +57,12 @@ class Migration(migrations.Migration): ("is_active", models.BooleanField(default=True)), ("confirmation_token", models.UUIDField(default=uuid.uuid4, editable=False)), ("confirmed", models.BooleanField(default=False)), - ("token_created_at", models.DateTimeField( - default=django.utils.timezone.now, help_text="Timestamp when confirmation token was created" - )), + ( + "token_created_at", + models.DateTimeField( + default=django.utils.timezone.now, help_text="Timestamp when confirmation token was created" + ), + ), ("wants_bug_reports", models.BooleanField(default=True)), ("wants_leaderboard_updates", models.BooleanField(default=True)), ("wants_security_news", models.BooleanField(default=True)), diff --git a/website/models.py b/website/models.py index ccb1f753fe..7f76180e90 100644 --- a/website/models.py +++ b/website/models.py @@ -3554,31 +3554,56 @@ def format_for_email(self): # Allowed tags and attributes for email HTML allowed_tags = [ - 'p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'blockquote', - 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'table', - 'thead', 'tbody', 'tr', 'th', 'td', 'img', 'div', 'span' + "p", + "br", + "strong", + "em", + "u", + "a", + "ul", + "ol", + "li", + "blockquote", + "code", + "pre", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "img", + "div", + "span", ] - + allowed_attributes = { - 'a': ['href', 'title', 'rel'], - 'img': ['src', 'alt', 'title', 'width', 'height'], - 'code': ['class'], - 'div': ['class'], - 'span': ['class'], - 'th': ['align'], - 'td': ['align'], + "a": ["href", "title", "rel"], + "img": ["src", "alt", "title", "width", "height"], + "code": ["class"], + "div": ["class"], + "span": ["class"], + "th": ["align"], + "td": ["align"], } # Convert Markdown content to HTML html_content = markdown.markdown(self.content, extensions=["extra", "codehilite"]) - + # Sanitize HTML to prevent XSS sanitized_content = bleach.clean( html_content, tags=allowed_tags, attributes=allowed_attributes, - protocols=['http', 'https', 'mailto'], - strip=True + protocols=["http", "https", "mailto"], + strip=True, ) # Return formatted content for email diff --git a/website/templates/newsletter/detail.html b/website/templates/newsletter/detail.html index b68a5f44c9..3ff1341303 100644 --- a/website/templates/newsletter/detail.html +++ b/website/templates/newsletter/detail.html @@ -15,7 +15,7 @@
  1. + class="inline-flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400"> Newsletters + class="ml-1 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 md:ml-2">Newsletters

  • @@ -48,7 +48,7 @@ viewBox="0 0 6 10"> - {{ newsletter.title }} + {{ newsletter.title }}
  • @@ -56,7 +56,7 @@
    -
    +
    {% if newsletter.featured_image %} {% endif %}
    -

    {{ newsletter.title }}

    -
    +

    {{ newsletter.title }}

    +
    Published on {{ newsletter.published_at|date:"F j, Y" }} {{ newsletter.view_count }} views
    -
    {{ newsletter.content|markdown_filter }}
    +
    {{ newsletter.content|markdown_filter }}
    {% if newsletter.recent_bugs_section and recent_bugs %}
    -

    Recent Security Findings

    -
    +

    Recent Security Findings

    +

  • + class="flex items-center text-blue-600 dark:text-blue-400 hover:underline"> Newsletter Options {% if request.user.is_authenticated %}
  • + class="flex items-center text-blue-600 dark:text-blue-400 hover:underline"> diff --git a/website/templates/newsletter/email/newsletter_email.html b/website/templates/newsletter/email/newsletter_email.html index c66fe942f7..f52ee84291 100644 --- a/website/templates/newsletter/email/newsletter_email.html +++ b/website/templates/newsletter/email/newsletter_email.html @@ -1,3 +1,4 @@ +{# djlint:off H021 #} diff --git a/website/templates/newsletter/home.html b/website/templates/newsletter/home.html index 5c35f2ba5b..3c0084b40e 100644 --- a/website/templates/newsletter/home.html +++ b/website/templates/newsletter/home.html @@ -15,12 +15,12 @@
    -

    Newsletters

    +

    Newsletters

    {% if messages %}
    {% for message in messages %} -
    +
    {{ message }}
    {% endfor %} @@ -28,7 +28,7 @@

    Newsletters

    {% endif %} {% if featured_newsletter %} -
    +
    {% if featured_newsletter.featured_image %}
    Newsletters
    {% endif %}
    - {% endif %}
    -

    All Newsletters

    +

    All Newsletters

    {% if newsletters %} -
    -
      +
      +
        {% for newsletter in newsletters %} -
      • +
      • -

        {{ newsletter.title }}

        -
        +

        {{ newsletter.title }}

        +
        All Newsletters
        -
        -

        Subscribe to Newsletter

        -

        Stay updated with our latest security news and bug reports.

        +
        +

        Subscribe to Newsletter

        +

        Stay updated with our latest security news and bug reports.

        {% if user.is_authenticated and newsletter_subscription.subscribed %} {% if newsletter_subscription.is_active %} {% if newsletter_subscription.confirmed %} -
        +

        You're subscribed to our newsletter!

        Subscribe to Newsletter {% else %} -
        +

        Please confirm your subscription by checking your email.

        {% endif %} diff --git a/website/templates/newsletter/preferences.html b/website/templates/newsletter/preferences.html index 7884041d82..bcc76a3fcf 100644 --- a/website/templates/newsletter/preferences.html +++ b/website/templates/newsletter/preferences.html @@ -12,24 +12,24 @@ {% if messages %}
        {% for message in messages %} -
        +
        {{ message }}
        {% endfor %}
        {% endif %} -
        +
        -

        Newsletter Preferences

        +

        Newsletter Preferences

        - {{ subscriber.subscription_status }} + {{ subscriber.subscription_status }}
        {% if not subscriber.confirmed and subscriber.is_active %} -
        +
        Newsletter Preferences
        -

        +

        Your email address has not been confirmed yet. Please check your inbox for a confirmation email. Newsletter Preferences

        {% csrf_token %}
        - +
        Newsletter Preferences value="{{ subscriber.email }}" placeholder="Your email address" disabled - class="block w-full rounded-md border-gray-300 bg-gray-100 shadow-sm text-gray-500 sm:text-sm"> + class="block w-full rounded-md border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 shadow-sm text-gray-500 dark:text-gray-400 sm:text-sm">
        - Content Preferences + Content Preferences
        @@ -74,11 +74,12 @@

        Newsletter Preferences

        name="wants_bug_reports" type="checkbox" {% if subscriber.wants_bug_reports %}checked{% endif %} - class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"> + class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
        - -

        Receive updates about recently reported security issues

        + +

        Receive updates about recently reported security issues

        @@ -87,11 +88,14 @@

        Newsletter Preferences

        name="wants_leaderboard_updates" type="checkbox" {% if subscriber.wants_leaderboard_updates %}checked{% endif %} - class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"> + class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
        - -

        Get notifications about changes in the community leaderboard

        + +

        Get notifications about changes in the community leaderboard

        @@ -100,22 +104,25 @@

        Newsletter Preferences

        name="wants_security_news" type="checkbox" {% if subscriber.wants_security_news %}checked{% endif %} - class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"> + class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
        - -

        Stay informed about important security news and analysis

        + +

        Stay informed about important security news and analysis

        -
        +
        Unsubscribe @@ -126,18 +133,18 @@

        Newsletter Preferences

        {% if recent_newsletters %}
        -

        Recent Newsletters

        -
        -
          +

          Recent Newsletters

          +
          +
            {% for newsletter in recent_newsletters %}
          • + class="block hover:bg-gray-50 dark:hover:bg-gray-700"> diff --git a/website/templates/newsletter/subscribe.html b/website/templates/newsletter/subscribe.html index c184335d09..955c6dde1f 100644 --- a/website/templates/newsletter/subscribe.html +++ b/website/templates/newsletter/subscribe.html @@ -10,10 +10,10 @@ {% include "includes/sidenav.html" %}
            -
            +
            - -

            Subscribe to Our Newsletter

            -

            Stay updated with the latest security findings and community achievements

            +

            Subscribe to Our Newsletter

            +

            + Stay updated with the latest security findings and community achievements +

            {% csrf_token %}
            - +
            - +
            @@ -55,12 +63,12 @@

            Subscribe to Our Newsletter

            - -

            + +

            We'll handle your data according to our privacy policy and will never share it with third parties.

            @@ -76,14 +84,14 @@

            Subscribe to Our Newsletter
            -
            +
            - Newsletter benefits + Newsletter benefits
            -
            +
            Subscribe to Our Newsletter
            -

            Latest Bug Reports

            -
            +

            Latest Bug Reports

            +

            Stay informed about the latest security issues and vulnerabilities discovered in the wild.

            -
            +
            Latest Bug Reports

            -

            Community Updates

            -
            +

            Community Updates

            +

            Get updates on leaderboard changes, new features, and upcoming events in our community.

            -
            +
            Community Updates
            -

            Expert Security Insights

            -
            +

            Expert Security Insights

            +

            Gain insights from security experts and learn about best practices to protect your systems.

            @@ -143,11 +151,11 @@

            Expert Security Insights

            {% if request.user.is_authenticated %} -
            -

            +

            +

            Already have an account and want to manage your subscription preferences? Manage your newsletter preferences + class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">Manage your newsletter preferences

            {% endif %} diff --git a/website/templatetags/custom_filters.py b/website/templatetags/custom_filters.py index 9dc42c72fa..d5ff597cf7 100644 --- a/website/templatetags/custom_filters.py +++ b/website/templatetags/custom_filters.py @@ -12,22 +12,47 @@ # Allowed tags and attributes for sanitizing markdown HTML ALLOWED_TAGS = [ - 'p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'blockquote', - 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'table', - 'thead', 'tbody', 'tr', 'th', 'td', 'img', 'div', 'span' + "p", + "br", + "strong", + "em", + "u", + "a", + "ul", + "ol", + "li", + "blockquote", + "code", + "pre", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "img", + "div", + "span", ] ALLOWED_ATTRIBUTES = { - 'a': ['href', 'title', 'rel'], - 'img': ['src', 'alt', 'title', 'width', 'height'], - 'code': ['class'], - 'div': ['class'], - 'span': ['class'], - 'th': ['align'], - 'td': ['align'], + "a": ["href", "title", "rel"], + "img": ["src", "alt", "title", "width", "height"], + "code": ["class"], + "div": ["class"], + "span": ["class"], + "th": ["align"], + "td": ["align"], } -ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] +ALLOWED_PROTOCOLS = ["http", "https", "mailto"] @register.filter @@ -53,14 +78,10 @@ def markdown_filter(value): """Converts markdown text to HTML with XSS protection.""" # Convert markdown to HTML html = markdown.markdown(value, extensions=["extra", "nl2br", "sane_lists"]) - + # Sanitize HTML to prevent XSS sanitized = bleach.clean( - html, - tags=ALLOWED_TAGS, - attributes=ALLOWED_ATTRIBUTES, - protocols=ALLOWED_PROTOCOLS, - strip=True + html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES, protocols=ALLOWED_PROTOCOLS, strip=True ) - + return mark_safe(sanitized) From 29d9f030b7787ee3a8d0d2f3d57dc4084a95383b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:35:12 +0000 Subject: [PATCH 25/39] Fix migration conflict: renumber newsletter migration from 0252 to 0259 Co-authored-by: DonnieBLT <128622481+DonnieBLT@users.noreply.github.com> --- ...rsubscriber.py => 0259_newsletter_newslettersubscriber.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename website/migrations/{0252_newsletter_newslettersubscriber.py => 0259_newsletter_newslettersubscriber.py} (97%) diff --git a/website/migrations/0252_newsletter_newslettersubscriber.py b/website/migrations/0259_newsletter_newslettersubscriber.py similarity index 97% rename from website/migrations/0252_newsletter_newslettersubscriber.py rename to website/migrations/0259_newsletter_newslettersubscriber.py index b85ebd84db..446c34111c 100644 --- a/website/migrations/0252_newsletter_newslettersubscriber.py +++ b/website/migrations/0259_newsletter_newslettersubscriber.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.8 on 2025-11-23 04:38 +# Generated by Django 5.1.8 on 2025-12-02 16:33 import uuid @@ -11,7 +11,7 @@ class Migration(migrations.Migration): dependencies = [ - ("website", "0251_add_fields_to_management_command_log"), + ("website", "0258_add_slackchannel_model"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] From bd65f2f1bb546763e83c0e1a399bcf8e0633d770 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 00:29:14 +0530 Subject: [PATCH 26/39] feat: Integrate email subscription system from PR #4847 - Merged PR #4847's email subscription functionality into newsletter feature - Added subscribe/archive buttons to newsletter stats page header - Changed newsletter archive URL from /newsletter/ to /newsletter/archive/ to avoid conflicts - Fixed duplicate Meta class in StakingTransaction model (merge artifact) - Fixed Activity creation in newsletter_home view when no newsletters exist - Newsletter stats page at /newsletter/ with subscription at /newsletter/subscribe/ --- blt/urls.py | 16 ++++++++-------- website/models.py | 3 --- website/templates/newsletter.html | 32 ++++++++++++------------------- website/views/user.py | 4 ++-- 4 files changed, 22 insertions(+), 33 deletions(-) diff --git a/blt/urls.py b/blt/urls.py index 8cd28866a9..71c9aa19c6 100644 --- a/blt/urls.py +++ b/blt/urls.py @@ -1218,6 +1218,14 @@ path("reminder-settings/", reminder_settings, name="reminder_settings"), path("send-test-reminder/", send_test_reminder, name="send_test_reminder"), path("check_domain_security_txt/", check_domain_security_txt, name="check_domain_security_txt"), + # Newsletter URLs + path("newsletter/archive/", newsletter_home, name="newsletter_home"), + path("newsletter/subscribe/", newsletter_subscribe, name="newsletter_subscribe"), + path("newsletter/confirm//", newsletter_confirm, name="newsletter_confirm"), + path("newsletter/unsubscribe//", newsletter_unsubscribe, name="newsletter_unsubscribe"), + path("newsletter/preferences/", newsletter_preferences, name="newsletter_preferences"), + path("newsletter/resend-confirmation/", newsletter_resend_confirmation, name="newsletter_resend_confirmation"), + path("newsletter//", newsletter_detail, name="newsletter_detail"), path("bounty_payout/", bounty_payout, name="bounty_payout"), path("api/trademarks/search/", trademark_search_api, name="api_trademark_search"), # Duplicate Bug Checking API @@ -1228,14 +1236,6 @@ ), path("api/v1/bugs/check-duplicate/", CheckDuplicateBugApiView.as_view(), name="api_check_duplicate_bug"), path("api/v1/bugs/find-similar/", FindSimilarBugsApiView.as_view(), name="api_find_similar_bugs"), - # Newsletter URLs - path("newsletter/archive/", newsletter_home, name="newsletter_home"), - path("newsletter/subscribe/", newsletter_subscribe, name="newsletter_subscribe"), - path("newsletter/confirm//", newsletter_confirm, name="newsletter_confirm"), - path("newsletter/unsubscribe//", newsletter_unsubscribe, name="newsletter_unsubscribe"), - path("newsletter/preferences/", newsletter_preferences, name="newsletter_preferences"), - path("newsletter/resend-confirmation/", newsletter_resend_confirmation, name="newsletter_resend_confirmation"), - path("newsletter//", newsletter_detail, name="newsletter_detail"), ] if settings.DEBUG: diff --git a/website/models.py b/website/models.py index 7f76180e90..506fd442bc 100644 --- a/website/models.py +++ b/website/models.py @@ -3458,9 +3458,6 @@ class Meta: def __str__(self): return f"{self.user.username} - {self.get_transaction_type_display()} - {self.amount} BACON" - class Meta: - ordering = ["is_read", "-created_at"] - class Newsletter(models.Model): PUBLICATION_STATUS = ( diff --git a/website/templates/newsletter.html b/website/templates/newsletter.html index 9989cb656c..ba30fb57cd 100644 --- a/website/templates/newsletter.html +++ b/website/templates/newsletter.html @@ -26,9 +26,19 @@

            BLT Newsletter

            -

            +

            Your monthly digest of bugs, contributors, releases, and project statistics

            +
            @@ -37,7 +47,7 @@

            Platform Statistics

            -
            +
            {{ total_bugs|intcomma }}
            Total Bugs
            @@ -55,24 +65,6 @@

            Active Bug Hunts

            -
            -
            -
            {{ open_bugs|intcomma }}
            -
            Open Bugs
            -
            -
            -
            {{ closed_bugs|intcomma }}
            -
            Closed Bugs
            -
            -
            -
            {{ total_domains|intcomma }}
            -
            Domains
            -
            -
            -
            {{ total_organizations|intcomma }}
            -
            Organizations
            -
            -
            diff --git a/website/views/user.py b/website/views/user.py index 54de7a94f3..e6981fba5b 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -1691,13 +1691,13 @@ def newsletter_home(request): featured_newsletter = Newsletter.objects.filter(status="published").order_by("-published_at").first() - if request.user.is_authenticated: + if request.user.is_authenticated and featured_newsletter: Activity.objects.create( user=request.user, action_type="view", title="Viewed newsletters", content_type=ContentType.objects.get_for_model(Newsletter), - object_id=featured_newsletter.id if featured_newsletter else None, + object_id=featured_newsletter.id, ) return render( From 824915665b7b0abb4b971da40937a75b0de4d7f7 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 00:47:52 +0530 Subject: [PATCH 27/39] fix: Address security vulnerabilities and code review issues - Replace vulnerable email regex with Django's validate_email (ReDoS fix) - Replace bare except: with except Exception: - Use generic error message instead of exposing exception details - Remove redundant logging import in newsletter_context_processor - Remove unnecessary pass statement - Move inline JavaScript to separate static file (newsletter_preferences.js) - Revert django-gravatar2 to ^1.4.5 --- pyproject.toml | 2 +- website/static/js/newsletter_preferences.js | 50 +++++++++++++++++++ website/templates/newsletter/preferences.html | 49 ++---------------- website/views/core.py | 5 -- website/views/user.py | 16 ++++-- 5 files changed, 66 insertions(+), 56 deletions(-) create mode 100644 website/static/js/newsletter_preferences.js diff --git a/pyproject.toml b/pyproject.toml index ba94fc259a..af23106b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dj-database-url = "^2.3.0" django-allauth = "^65.13.1" beautifulsoup4 = "^4.13.3" django-email-obfuscator = "^0.1.5" -django-gravatar2 = "1.4.4" +django-gravatar2 = "^1.4.5" django-import-export = "^4.3.14" django-annoying = "^0.10.7" dj-rest-auth = "^5.0.2" diff --git a/website/static/js/newsletter_preferences.js b/website/static/js/newsletter_preferences.js new file mode 100644 index 0000000000..cb567dde0c --- /dev/null +++ b/website/static/js/newsletter_preferences.js @@ -0,0 +1,50 @@ +document.addEventListener("DOMContentLoaded", function () { + // Handle resend confirmation email click + const resendLink = document.getElementById("resend-confirmation"); + if (resendLink) { + const subscriberEmail = resendLink.getAttribute("data-email"); + const resendUrl = resendLink.getAttribute("data-resend-url"); + + resendLink.addEventListener("click", function (e) { + e.preventDefault(); + + // Show loading state + const originalText = resendLink.textContent; + resendLink.textContent = "Sending..."; + resendLink.style.pointerEvents = "none"; + + // Get CSRF token from cookie or meta tag + const csrfToken = + document.querySelector("[name=csrfmiddlewaretoken]")?.value || + document.querySelector('meta[name="csrf-token"]')?.content; + + // Make AJAX request to resend confirmation + fetch(resendUrl, { + method: "POST", + headers: { + "X-CSRFToken": csrfToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: subscriberEmail, + }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + window.createMessage("Confirmation email has been sent.", "success"); + } else { + window.createMessage("Failed to send confirmation email. Please try again.", "error"); + } + }) + .catch(() => { + window.createMessage("An error occurred. Please try again.", "error"); + }) + .finally(() => { + // Reset button state + resendLink.textContent = originalText; + resendLink.style.pointerEvents = "auto"; + }); + }); + } +}); diff --git a/website/templates/newsletter/preferences.html b/website/templates/newsletter/preferences.html index bcc76a3fcf..393c7f7396 100644 --- a/website/templates/newsletter/preferences.html +++ b/website/templates/newsletter/preferences.html @@ -45,7 +45,8 @@

            Newsletter Prefe Resend confirmation email + data-email="{{ subscriber.email }}" + data-resend-url="{% url 'newsletter_resend_confirmation' %}">Resend confirmation email

            @@ -163,49 +164,5 @@

            Recent Newsl

            - + {% endblock %} diff --git a/website/views/core.py b/website/views/core.py index d4f727f5d0..d6cb0c587b 100644 --- a/website/views/core.py +++ b/website/views/core.py @@ -3281,10 +3281,6 @@ def newsletter_context_processor(request): """ Adds newsletter subscription data to the template context """ - import logging - - logger = logging.getLogger(__name__) - context = {} if request.user.is_authenticated: @@ -3316,6 +3312,5 @@ def newsletter_context_processor(request): context["latest_newsletter"] = Newsletter.objects.filter(status="published").order_by("-published_at").first() except Exception as e: logger.error(f"Error fetching latest newsletter: {str(e)}") - pass return context diff --git a/website/views/user.py b/website/views/user.py index e6981fba5b..1e1741c74c 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -22,6 +22,7 @@ from django.core.exceptions import ValidationError from django.core.mail import send_mail from django.core.paginator import Paginator +from django.core.validators import validate_email from django.db.models import Count, F, Q, Sum from django.db.models.functions import ExtractMonth from django.dispatch import receiver @@ -1786,7 +1787,9 @@ def newsletter_subscribe(request): messages.error(request, "Email address is required.") return redirect("newsletter_subscribe") - if not re.match(r"[^@]+@[^@]+\.[^@]+", email): + try: + validate_email(email) + except ValidationError: messages.error(request, "Please enter a valid email address.") return redirect("newsletter_subscribe") @@ -1841,7 +1844,7 @@ def newsletter_subscribe(request): except ValidationError as e: logger.error(f"Validation error in newsletter subscription: {str(e)}") - messages.error(request, f"There was an error processing your subscription: {str(e)}") + messages.error(request, "There was an error processing your subscription. Please try again later.") except ConnectionError as e: logger.error(f"Connection error sending confirmation email: {str(e)}") messages.error(request, "We couldn't send a confirmation email right now. Please try again later.") @@ -1866,7 +1869,7 @@ def send_confirmation_email(subscriber): # Use site framework or ALLOWED_HOSTS for better domain management try: domain = Site.objects.get_current().domain - except: + except Exception: domain = settings.DOMAIN_NAME if settings.DEBUG: @@ -2072,7 +2075,12 @@ def newsletter_resend_confirmation(request): data = json.loads(request.body) email = data.get("email") - if not email or not re.match(r"[^@]+@[^@]+\.[^@]+", email): + if not email: + return JsonResponse({"success": False, "error": "Valid email address is required"}) + + try: + validate_email(email) + except ValidationError: return JsonResponse({"success": False, "error": "Valid email address is required"}) # Use the same error message and timing regardless of whether the email exists From 3ebb7b44b363a8644d0fef427a43483a460be42d Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 06:21:59 +0530 Subject: [PATCH 28/39] fix: address Copilot review comments - Remove style tags from email templates (use inline styles only) - Fix race condition in newsletter subscription with select_for_update() - Extract Slack notification logic to reusable function - Update ip_restrict.py comment for clarity - Remove error IDs from user-facing messages --- blt/middleware/ip_restrict.py | 2 +- .../newsletter/email/confirmation_email.html | 335 ++++------------- .../newsletter/email/newsletter_email.html | 336 +++++------------- website/views/bitcoin.py | 232 +++++++----- website/views/user.py | 55 +-- 5 files changed, 324 insertions(+), 636 deletions(-) diff --git a/blt/middleware/ip_restrict.py b/blt/middleware/ip_restrict.py index 352fcc6c1a..39060c96a1 100644 --- a/blt/middleware/ip_restrict.py +++ b/blt/middleware/ip_restrict.py @@ -161,7 +161,7 @@ def _record_ip(self, ip, agent, path): if ip_record.pk: ip_record.save(update_fields=["agent", "count"]) - # Delete duplicate records within the same atomic block + # Clean up any duplicate records that may exist from before locking was implemented ip_records.exclude(pk=ip_record.pk).delete() else: # If no record exists, create a new one diff --git a/website/templates/newsletter/email/confirmation_email.html b/website/templates/newsletter/email/confirmation_email.html index 35c8b20e9a..2f8dc47380 100644 --- a/website/templates/newsletter/email/confirmation_email.html +++ b/website/templates/newsletter/email/confirmation_email.html @@ -10,265 +10,78 @@ content="OWASP, BLT, Newsletter, Subscription, Confirmation"> Codestin Search App - - - - - - - - - - - + + + + + + + + + + + diff --git a/website/templates/newsletter/email/newsletter_email.html b/website/templates/newsletter/email/newsletter_email.html index f52ee84291..7c8dd353df 100644 --- a/website/templates/newsletter/email/newsletter_email.html +++ b/website/templates/newsletter/email/newsletter_email.html @@ -10,253 +10,91 @@ content="OWASP, BLT, Newsletter, Security, Bug Logging Tool"> Codestin Search App - - - - - - - - - - - + + + + + + + + + + + diff --git a/website/views/bitcoin.py b/website/views/bitcoin.py index 471ba6d744..5b34d61181 100644 --- a/website/views/bitcoin.py +++ b/website/views/bitcoin.py @@ -40,6 +40,132 @@ def slack_escape(text): ) +def send_bacon_submission_slack_notification(submission, username, contribution_type, github_url, description, status): + """ + Send Slack notification for BACON submission to #project-blt-bacon channel. + + Args: + submission: The BaconSubmission instance + username: The username of the submitter + contribution_type: Type of contribution (security/non-security) + github_url: URL to the GitHub PR + description: Description of the contribution + status: Current status of the submission + """ + try: + # Find OWASP BLT organization + # Use exact match first, fallback to case-insensitive contains for flexibility + owasp_org = Organization.objects.filter(name="OWASP BLT").first() + if not owasp_org: + owasp_org = Organization.objects.filter(name__icontains="OWASP BLT").first() + + if not owasp_org: + logger.warning("OWASP BLT organization not found") + return + + # Get Slack integration for the organization + slack_integration = SlackIntegration.objects.filter(integration__organization=owasp_org).first() + + if not slack_integration or not slack_integration.bot_access_token: + logger.warning("Slack integration not configured for OWASP BLT") + return + + # Get credentials from database + bot_token = slack_integration.bot_access_token + channel_id = slack_integration.default_channel_id + + # Create WebClient once for reuse + client = WebClient(token=bot_token) + + # If no default channel ID is set, try to find #project-blt-bacon specifically + # Handle pagination to ensure we check all channels + if not channel_id: + channel_id = _find_slack_channel(client, "project-blt-bacon") + + # Send notification if we have a channel ID + if not channel_id: + logger.warning("Slack channel #project-blt-bacon not found and no default channel configured") + return + + # Sanitize description for Slack markdown (escape special characters) + # Slack markdown uses *, _, `, ~, <, >, & for formatting + sanitized_description = description[:200] + sanitized_description = slack_escape(sanitized_description) + if len(description) > 200: + sanitized_description += "..." + + # Escape username and other user-provided fields for Slack markdown + escaped_username = slack_escape(username) + escaped_type = slack_escape(contribution_type) + escaped_status = slack_escape(status) + + # Build the message + message = ( + f"*New BACON Claim Submitted!*\n\n" + f"• *User:* {escaped_username}\n" + f"• *Type:* {escaped_type}\n" + f"• *PR:* {github_url}\n" + f"• *Description:* {sanitized_description}\n" + f"• *Amount:* {submission.bacon_amount} BACON\n" + f"• *Status:* {escaped_status}" + ) + + # Send to Slack + try: + client.chat_postMessage( + channel=channel_id, + text=message, + unfurl_links=False, + ) + logger.info("Slack notification sent for submission %s", submission.id) + except SlackApiError as e: + logger.error( + "Failed to send Slack message to channel %s: %s", + channel_id, + e, + exc_info=True, + ) + + except Exception as e: + # Don't fail the submission if Slack fails + logger.error("Failed to send Slack notification: %s", e, exc_info=True) + + +def _find_slack_channel(client, channel_name): + """ + Find a Slack channel by name, handling pagination. + + Args: + client: Slack WebClient instance + channel_name: Name of the channel to find (without #) + + Returns: + Channel ID if found, None otherwise + """ + try: + cursor = None + while True: + channels_response = client.conversations_list(types="public_channel", cursor=cursor) + if channels_response.get("ok"): + for channel in channels_response.get("channels", []): + if channel.get("name") == channel_name: + return channel.get("id") + # Check for next page + cursor = channels_response.get("response_metadata", {}).get("next_cursor") + if not cursor: + break + else: + logger.warning( + "Failed to list Slack channels: %s", + channels_response.get("error"), + ) + break + except SlackApiError as e: + logger.warning("Failed to find #%s channel: %s", channel_name, e) + + return None + + # @login_required def batch_send_bacon_tokens_view(request): # Get all users with non-zero tokens_earned @@ -143,104 +269,14 @@ def post(self, request): ) # Send Slack notification to #project-blt-bacon channel - try: - # Find OWASP BLT organization - # Use exact match first, fallback to case-insensitive contains for flexibility - owasp_org = Organization.objects.filter(name="OWASP BLT").first() - if not owasp_org: - owasp_org = Organization.objects.filter(name__icontains="OWASP BLT").first() - - if owasp_org: - # Get Slack integration for the organization - slack_integration = SlackIntegration.objects.filter(integration__organization=owasp_org).first() - - if slack_integration and slack_integration.bot_access_token: - # Get credentials from database - bot_token = slack_integration.bot_access_token - channel_id = slack_integration.default_channel_id - - # Create WebClient once for reuse - client = WebClient(token=bot_token) - - # If no default channel ID is set, try to find #project-blt-bacon specifically - # Handle pagination to ensure we check all channels - if not channel_id: - try: - cursor = None - while True: - channels_response = client.conversations_list(types="public_channel", cursor=cursor) - if channels_response.get("ok"): - for channel in channels_response.get("channels", []): - if channel.get("name") == "project-blt-bacon": - channel_id = channel.get("id") - break - if channel_id: - break - # Check for next page - cursor = channels_response.get("response_metadata", {}).get("next_cursor") - if not cursor: - break - else: - logger.warning( - "Failed to list Slack channels: %s", - channels_response.get("error"), - ) - break - except SlackApiError as e: - logger.warning("Failed to find #project-blt-bacon channel: %s", e) - - # Send notification if we have a channel ID - if channel_id: - # Sanitize description for Slack markdown (escape special characters) - # Slack markdown uses *, _, `, ~, <, >, & for formatting - sanitized_description = description[:200] - sanitized_description = slack_escape(sanitized_description) - if len(description) > 200: - sanitized_description += "..." - - # Escape username and other user-provided fields for Slack markdown - escaped_username = slack_escape(request.user.username) - escaped_type = slack_escape(contribution_type) - escaped_status = slack_escape(status) - - # Build the message - message = ( - f"*New BACON Claim Submitted!*\n\n" - f"• *User:* {escaped_username}\n" - f"• *Type:* {escaped_type}\n" - f"• *PR:* {github_url}\n" - f"• *Description:* {sanitized_description}\n" - f"• *Amount:* {bacon_amount} BACON\n" - f"• *Status:* {escaped_status}" - ) - - # Send to Slack - try: - client.chat_postMessage( - channel=channel_id, - text=message, - unfurl_links=False, - ) - logger.info("Slack notification sent for submission %s", submission.id) - except SlackApiError as e: - logger.error( - "Failed to send Slack message to channel %s: %s", - channel_id, - e, - exc_info=True, - ) - else: - logger.warning( - "Slack channel #project-blt-bacon not found and no default channel configured" - ) - else: - logger.warning("Slack integration not configured for OWASP BLT") - else: - logger.warning("OWASP BLT organization not found") - - except Exception as e: - # Don't fail the submission if Slack fails - logger.error("Failed to send Slack notification: %s", e, exc_info=True) + send_bacon_submission_slack_notification( + submission=submission, + username=request.user.username, + contribution_type=contribution_type, + github_url=github_url, + description=description, + status=status, + ) return JsonResponse({"message": "Submission created", "submission_id": submission.id}, status=201) diff --git a/website/views/user.py b/website/views/user.py index 1e1741c74c..0d79e3f03b 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -23,6 +23,7 @@ from django.core.mail import send_mail from django.core.paginator import Paginator from django.core.validators import validate_email +from django.db import transaction from django.db.models import Count, F, Q, Sum from django.db.models.functions import ExtractMonth from django.dispatch import receiver @@ -1796,26 +1797,30 @@ def newsletter_subscribe(request): cache.set(rate_key, rate_attempts + 1, 3600) try: - # Update any existing active subscriptions for this email to inactive - NewsletterSubscriber.objects.filter(email=email).exclude(is_active=False).update(is_active=False) - - subscriber, created = NewsletterSubscriber.objects.get_or_create( - email=email, - defaults={ - "name": name, - "user": request.user if request.user.is_authenticated else None, - "is_active": True, - "confirmed": False, - "token_created_at": timezone.now(), - }, - ) + with transaction.atomic(): + # Update any existing active subscriptions for this email to inactive + # Use select_for_update() to prevent race conditions + NewsletterSubscriber.objects.select_for_update().filter(email=email).exclude(is_active=False).update( + is_active=False + ) + + subscriber, created = NewsletterSubscriber.objects.get_or_create( + email=email, + defaults={ + "name": name, + "user": request.user if request.user.is_authenticated else None, + "is_active": True, + "confirmed": False, + "token_created_at": timezone.now(), + }, + ) - if not created: - subscriber.is_active = True - subscriber.name = name if name else subscriber.name - subscriber.confirmation_token = uuid.uuid4() - subscriber.token_created_at = timezone.now() - subscriber.save(update_fields=["is_active", "name", "confirmation_token", "token_created_at"]) + if not created: + subscriber.is_active = True + subscriber.name = name if name else subscriber.name + subscriber.confirmation_token = uuid.uuid4() + subscriber.token_created_at = timezone.now() + subscriber.save(update_fields=["is_active", "name", "confirmation_token", "token_created_at"]) last_sent_key = f"last_subscribe_email_{email}" last_sent = cache.get(last_sent_key) @@ -1849,11 +1854,10 @@ def newsletter_subscribe(request): logger.error(f"Connection error sending confirmation email: {str(e)}") messages.error(request, "We couldn't send a confirmation email right now. Please try again later.") except Exception as e: - error_id = uuid.uuid4() - logger.error(f"Error ID: {error_id} - Error in newsletter subscription: {str(e)}", exc_info=True) + logger.error(f"Error in newsletter subscription: {str(e)}", exc_info=True) messages.error( request, - f"There was an error processing your subscription (Error ID: {error_id}). Please try again later.", + "There was an error processing your subscription. Please try again later.", ) return redirect("newsletter_home") @@ -2019,11 +2023,8 @@ def newsletter_unsubscribe(request, token): messages.error(request, "Invalid unsubscribe link. Please contact support if you need assistance.") return redirect("home") except Exception as e: - error_id = uuid.uuid4() - logger.error(f"Error ID: {error_id} - Error in newsletter unsubscribe: {str(e)}", exc_info=True) - messages.error( - request, f"An error occurred while processing your request (Error ID: {error_id}). Please try again later." - ) + logger.error(f"Error in newsletter unsubscribe: {str(e)}", exc_info=True) + messages.error(request, "An error occurred while processing your request. Please try again later.") return redirect("home") From 8f09f1303382dc88997a5ab4804221c7918971cd Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 06:39:28 +0530 Subject: [PATCH 29/39] fix: remove select_for_update() for SQLite test compatibility --- website/views/user.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/website/views/user.py b/website/views/user.py index 0d79e3f03b..bc3c01a982 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -1799,10 +1799,7 @@ def newsletter_subscribe(request): try: with transaction.atomic(): # Update any existing active subscriptions for this email to inactive - # Use select_for_update() to prevent race conditions - NewsletterSubscriber.objects.select_for_update().filter(email=email).exclude(is_active=False).update( - is_active=False - ) + NewsletterSubscriber.objects.filter(email=email).exclude(is_active=False).update(is_active=False) subscriber, created = NewsletterSubscriber.objects.get_or_create( email=email, From 5cc85ff55c7d722331643dd76ee0880294dc1abd Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 16:29:15 +0530 Subject: [PATCH 30/39] fix: improve exception handling with logger.exception() and proper chaining - Replace logger.error() with logger.exception() for automatic traceback capture - Add 'from None' to RuntimeError raises to suppress original exception after logging - Catch Site.DoesNotExist specifically instead of bare Exception - Remove error IDs from user-facing messages (per previous review comment) --- website/views/user.py | 50 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/website/views/user.py b/website/views/user.py index bc3c01a982..0c52180720 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -1844,14 +1844,14 @@ def newsletter_subscribe(request): logger.info(f"Subscription reactivated for {email}") messages.success(request, "Your subscription has been reactivated. Please confirm your email.") - except ValidationError as e: - logger.error(f"Validation error in newsletter subscription: {str(e)}") + except ValidationError: + logger.exception("Validation error in newsletter subscription") messages.error(request, "There was an error processing your subscription. Please try again later.") - except ConnectionError as e: - logger.error(f"Connection error sending confirmation email: {str(e)}") + except ConnectionError: + logger.exception("Connection error sending confirmation email") messages.error(request, "We couldn't send a confirmation email right now. Please try again later.") - except Exception as e: - logger.error(f"Error in newsletter subscription: {str(e)}", exc_info=True) + except Exception: + logger.exception("Error in newsletter subscription") messages.error( request, "There was an error processing your subscription. Please try again later.", @@ -1870,7 +1870,7 @@ def send_confirmation_email(subscriber): # Use site framework or ALLOWED_HOSTS for better domain management try: domain = Site.objects.get_current().domain - except Exception: + except Site.DoesNotExist: domain = settings.DOMAIN_NAME if settings.DEBUG: @@ -1901,14 +1901,14 @@ def send_confirmation_email(subscriber): fail_silently=False, ) logger.info(f"Confirmation email sent to: {subscriber.email}") - except ConnectionRefusedError as e: - logger.error(f"Email server connection refused when sending to {subscriber.email}: {str(e)}") - raise RuntimeError("Could not connect to email server") - except smtplib.SMTPException as e: - logger.error(f"SMTP error when sending to {subscriber.email}: {str(e)}") - raise RuntimeError("Email server rejected the message") - except Exception as e: - logger.error(f"Failed to send confirmation email to {subscriber.email}: {str(e)}") + except ConnectionRefusedError: + logger.exception(f"Email server connection refused when sending to {subscriber.email}") + raise RuntimeError("Could not connect to email server") from None + except smtplib.SMTPException: + logger.exception(f"SMTP error when sending to {subscriber.email}") + raise RuntimeError("Email server rejected the message") from None + except Exception: + logger.exception(f"Failed to send confirmation email to {subscriber.email}") raise @@ -1928,9 +1928,9 @@ def newsletter_confirm(request, token): request, "Your confirmation link has expired. We've sent a new confirmation email to your address." ) return redirect("newsletter_home") - except AttributeError as e: + except AttributeError: # This would happen if is_token_expired method doesn't exist or implementation changed - logger.error(f"Token expiration check failed due to AttributeError: {str(e)}") + logger.exception("Token expiration check failed due to AttributeError") messages.error(request, "There was a system error processing your request. Our team has been notified.") return redirect("newsletter_home") @@ -1948,13 +1948,11 @@ def newsletter_confirm(request, token): logger.warning(f"Invalid confirmation token used: {token}") messages.error(request, "The confirmation link is invalid or has already been used.") return redirect("newsletter_home") - except Exception as e: - error_id = uuid.uuid4() - logger.error(f"Error ID: {error_id} - Unexpected error in newsletter confirmation: {str(e)}", exc_info=True) + except Exception: + logger.exception("Unexpected error in newsletter confirmation") messages.error( request, - f"There was an unexpected error confirming your subscription (Error ID: {error_id}). " - "Please try again or contact support with this Error ID.", + "There was an unexpected error confirming your subscription. Please try again or contact support.", ) return redirect("newsletter_home") @@ -2019,8 +2017,8 @@ def newsletter_unsubscribe(request, token): logger.warning(f"Invalid token used for unsubscribe: {token}") messages.error(request, "Invalid unsubscribe link. Please contact support if you need assistance.") return redirect("home") - except Exception as e: - logger.error(f"Error in newsletter unsubscribe: {str(e)}", exc_info=True) + except Exception: + logger.exception("Error in newsletter unsubscribe") messages.error(request, "An error occurred while processing your request. Please try again later.") return redirect("home") @@ -2113,8 +2111,8 @@ def newsletter_resend_confirmation(request): return JsonResponse({"success": True}) # Return success even if email doesn't exist return JsonResponse({"success": True}) - except Exception as e: - logger.error(f"Error in newsletter_resend_confirmation: {str(e)}") + except Exception: + logger.exception("Error in newsletter_resend_confirmation") return JsonResponse( {"success": False, "error": "An error occurred while processing your request. Please try again later."} ) From 9e15b5aa4f6a5ca1d042c1a7c008d5b975f51e52 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 18:59:37 +0530 Subject: [PATCH 31/39] chore: revert bitcoin.py changes - out of scope for newsletter PR The Slack integration for BACON submissions should be addressed in a separate PR to keep this PR focused on newsletter functionality. --- website/views/bitcoin.py | 232 +++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 134 deletions(-) diff --git a/website/views/bitcoin.py b/website/views/bitcoin.py index 5b34d61181..471ba6d744 100644 --- a/website/views/bitcoin.py +++ b/website/views/bitcoin.py @@ -40,132 +40,6 @@ def slack_escape(text): ) -def send_bacon_submission_slack_notification(submission, username, contribution_type, github_url, description, status): - """ - Send Slack notification for BACON submission to #project-blt-bacon channel. - - Args: - submission: The BaconSubmission instance - username: The username of the submitter - contribution_type: Type of contribution (security/non-security) - github_url: URL to the GitHub PR - description: Description of the contribution - status: Current status of the submission - """ - try: - # Find OWASP BLT organization - # Use exact match first, fallback to case-insensitive contains for flexibility - owasp_org = Organization.objects.filter(name="OWASP BLT").first() - if not owasp_org: - owasp_org = Organization.objects.filter(name__icontains="OWASP BLT").first() - - if not owasp_org: - logger.warning("OWASP BLT organization not found") - return - - # Get Slack integration for the organization - slack_integration = SlackIntegration.objects.filter(integration__organization=owasp_org).first() - - if not slack_integration or not slack_integration.bot_access_token: - logger.warning("Slack integration not configured for OWASP BLT") - return - - # Get credentials from database - bot_token = slack_integration.bot_access_token - channel_id = slack_integration.default_channel_id - - # Create WebClient once for reuse - client = WebClient(token=bot_token) - - # If no default channel ID is set, try to find #project-blt-bacon specifically - # Handle pagination to ensure we check all channels - if not channel_id: - channel_id = _find_slack_channel(client, "project-blt-bacon") - - # Send notification if we have a channel ID - if not channel_id: - logger.warning("Slack channel #project-blt-bacon not found and no default channel configured") - return - - # Sanitize description for Slack markdown (escape special characters) - # Slack markdown uses *, _, `, ~, <, >, & for formatting - sanitized_description = description[:200] - sanitized_description = slack_escape(sanitized_description) - if len(description) > 200: - sanitized_description += "..." - - # Escape username and other user-provided fields for Slack markdown - escaped_username = slack_escape(username) - escaped_type = slack_escape(contribution_type) - escaped_status = slack_escape(status) - - # Build the message - message = ( - f"*New BACON Claim Submitted!*\n\n" - f"• *User:* {escaped_username}\n" - f"• *Type:* {escaped_type}\n" - f"• *PR:* {github_url}\n" - f"• *Description:* {sanitized_description}\n" - f"• *Amount:* {submission.bacon_amount} BACON\n" - f"• *Status:* {escaped_status}" - ) - - # Send to Slack - try: - client.chat_postMessage( - channel=channel_id, - text=message, - unfurl_links=False, - ) - logger.info("Slack notification sent for submission %s", submission.id) - except SlackApiError as e: - logger.error( - "Failed to send Slack message to channel %s: %s", - channel_id, - e, - exc_info=True, - ) - - except Exception as e: - # Don't fail the submission if Slack fails - logger.error("Failed to send Slack notification: %s", e, exc_info=True) - - -def _find_slack_channel(client, channel_name): - """ - Find a Slack channel by name, handling pagination. - - Args: - client: Slack WebClient instance - channel_name: Name of the channel to find (without #) - - Returns: - Channel ID if found, None otherwise - """ - try: - cursor = None - while True: - channels_response = client.conversations_list(types="public_channel", cursor=cursor) - if channels_response.get("ok"): - for channel in channels_response.get("channels", []): - if channel.get("name") == channel_name: - return channel.get("id") - # Check for next page - cursor = channels_response.get("response_metadata", {}).get("next_cursor") - if not cursor: - break - else: - logger.warning( - "Failed to list Slack channels: %s", - channels_response.get("error"), - ) - break - except SlackApiError as e: - logger.warning("Failed to find #%s channel: %s", channel_name, e) - - return None - - # @login_required def batch_send_bacon_tokens_view(request): # Get all users with non-zero tokens_earned @@ -269,14 +143,104 @@ def post(self, request): ) # Send Slack notification to #project-blt-bacon channel - send_bacon_submission_slack_notification( - submission=submission, - username=request.user.username, - contribution_type=contribution_type, - github_url=github_url, - description=description, - status=status, - ) + try: + # Find OWASP BLT organization + # Use exact match first, fallback to case-insensitive contains for flexibility + owasp_org = Organization.objects.filter(name="OWASP BLT").first() + if not owasp_org: + owasp_org = Organization.objects.filter(name__icontains="OWASP BLT").first() + + if owasp_org: + # Get Slack integration for the organization + slack_integration = SlackIntegration.objects.filter(integration__organization=owasp_org).first() + + if slack_integration and slack_integration.bot_access_token: + # Get credentials from database + bot_token = slack_integration.bot_access_token + channel_id = slack_integration.default_channel_id + + # Create WebClient once for reuse + client = WebClient(token=bot_token) + + # If no default channel ID is set, try to find #project-blt-bacon specifically + # Handle pagination to ensure we check all channels + if not channel_id: + try: + cursor = None + while True: + channels_response = client.conversations_list(types="public_channel", cursor=cursor) + if channels_response.get("ok"): + for channel in channels_response.get("channels", []): + if channel.get("name") == "project-blt-bacon": + channel_id = channel.get("id") + break + if channel_id: + break + # Check for next page + cursor = channels_response.get("response_metadata", {}).get("next_cursor") + if not cursor: + break + else: + logger.warning( + "Failed to list Slack channels: %s", + channels_response.get("error"), + ) + break + except SlackApiError as e: + logger.warning("Failed to find #project-blt-bacon channel: %s", e) + + # Send notification if we have a channel ID + if channel_id: + # Sanitize description for Slack markdown (escape special characters) + # Slack markdown uses *, _, `, ~, <, >, & for formatting + sanitized_description = description[:200] + sanitized_description = slack_escape(sanitized_description) + if len(description) > 200: + sanitized_description += "..." + + # Escape username and other user-provided fields for Slack markdown + escaped_username = slack_escape(request.user.username) + escaped_type = slack_escape(contribution_type) + escaped_status = slack_escape(status) + + # Build the message + message = ( + f"*New BACON Claim Submitted!*\n\n" + f"• *User:* {escaped_username}\n" + f"• *Type:* {escaped_type}\n" + f"• *PR:* {github_url}\n" + f"• *Description:* {sanitized_description}\n" + f"• *Amount:* {bacon_amount} BACON\n" + f"• *Status:* {escaped_status}" + ) + + # Send to Slack + try: + client.chat_postMessage( + channel=channel_id, + text=message, + unfurl_links=False, + ) + logger.info("Slack notification sent for submission %s", submission.id) + except SlackApiError as e: + logger.error( + "Failed to send Slack message to channel %s: %s", + channel_id, + e, + exc_info=True, + ) + else: + logger.warning( + "Slack channel #project-blt-bacon not found and no default channel configured" + ) + else: + logger.warning("Slack integration not configured for OWASP BLT") + else: + logger.warning("OWASP BLT organization not found") + + except Exception as e: + # Don't fail the submission if Slack fails + logger.error("Failed to send Slack notification: %s", e, exc_info=True) return JsonResponse({"message": "Submission created", "submission_id": submission.id}, status=201) From 8a1a0a56c8bfb4cfecc35c7c4728ac8dc31653d8 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 19:35:30 +0530 Subject: [PATCH 32/39] fix: address race conditions and remove test.db - Use cache.incr() for atomic rate limiting in unsubscribe view - Use cache.incr() for atomic rate limiting in resend confirmation view - Use atomic update() with confirmed=False filter to prevent double-confirmations - Remove test.db from repo and add *.db to .gitignore --- .gitignore | 1 + test.db | 0 website/views/user.py | 41 ++++++++++++++++++++++++++++------------- 3 files changed, 29 insertions(+), 13 deletions(-) delete mode 100644 test.db diff --git a/.gitignore b/.gitignore index 608ce7103f..5678a6cc92 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ staticfiles /env /pvenv db.sqlite3 +*.db .idea /media .vagrant diff --git a/test.db b/test.db deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/website/views/user.py b/website/views/user.py index 0c52180720..b49fee6bf9 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -1935,11 +1935,16 @@ def newsletter_confirm(request, token): return redirect("newsletter_home") if not subscriber.confirmed: - subscriber.confirmed = True - subscriber.save(update_fields=["confirmed"]) - messages.success(request, "Thank you! Your newsletter subscription has been confirmed.") + # Use atomic update to prevent race conditions with concurrent confirmation requests + # This ensures only one request can successfully confirm the subscription + updated = NewsletterSubscriber.objects.filter(pk=subscriber.pk, confirmed=False).update(confirmed=True) - logger.info(f"Successfully confirmed subscription for: {subscriber.email}") + if updated: + messages.success(request, "Thank you! Your newsletter subscription has been confirmed.") + logger.info(f"Successfully confirmed subscription for: {subscriber.email}") + else: + # Another request confirmed it between our check and update + messages.info(request, "Your subscription is already confirmed.") else: messages.info(request, "Your subscription is already confirmed.") @@ -1962,16 +1967,21 @@ def newsletter_unsubscribe(request, token): try: client_ip = get_client_ip(request) cache_key = f"unsubscribe_attempts_{client_ip}" - attempts = cache.get(cache_key, 0) + + # Use atomic increment for rate limiting to prevent race conditions + try: + attempts = cache.incr(cache_key) + except ValueError: + # Key doesn't exist, initialize it + cache.set(cache_key, 1, 3600) + attempts = 1 # Limit to 10 unsubscribe attempts per hour from the same IP - if attempts >= 10: + if attempts > 10: logger.warning(f"Unsubscribe rate limit exceeded from IP: {client_ip}") messages.error(request, "Too many unsubscribe attempts. Please try again later.") return redirect("home") - cache.set(cache_key, attempts + 1, 3600) - subscriber = get_object_or_404(NewsletterSubscriber, confirmation_token=token) # Add token creation time validation for extra security @@ -2059,12 +2069,18 @@ def newsletter_preferences(request): def newsletter_resend_confirmation(request): """AJAX view to resend newsletter confirmation email""" try: - # Rate limiting check + # Rate limiting check - use atomic increment to prevent race conditions client_ip = get_client_ip(request) cache_key = f"resend_confirm_rate_{client_ip}" - attempts = cache.get(cache_key, 0) - if attempts >= 3: + try: + attempts = cache.incr(cache_key) + except ValueError: + # Key doesn't exist, initialize it + cache.set(cache_key, 1, 1800) + attempts = 1 + + if attempts > 3: logger.warning(f"Rate limit exceeded for confirmation resend from IP: {client_ip}") return JsonResponse({"success": False, "error": "Too many attempts. Please try again later."}, status=429) @@ -2097,8 +2113,7 @@ def newsletter_resend_confirmation(request): {"success": False, "error": "Please wait at least 5 minutes before requesting another email."} ) - # Record this attempt - cache.set(cache_key, attempts + 1, 1800) + # Record email send timestamp cache.set(last_sent_key, time.time(), 3600) send_confirmation_email(subscriber) From d5a08ae81ac548370edd3dc256afe2ce13c436d3 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 19:42:10 +0530 Subject: [PATCH 33/39] fix: remove dead except block and simplify timing calculations - Replace get_object_or_404 with try/except to properly catch NewsletterSubscriber.DoesNotExist (get_object_or_404 raises Http404) - Simplify timing-attack prevention: max(0, min(2, 2-elapsed)) -> max(0, 2-elapsed) The inner min(2, ...) was redundant since 2-elapsed is always <= 2 --- website/views/user.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/website/views/user.py b/website/views/user.py index b49fee6bf9..820ee3fb72 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -1917,7 +1917,13 @@ def newsletter_confirm(request, token): try: logger.info(f"Newsletter confirmation attempt with token: {token}") - subscriber = get_object_or_404(NewsletterSubscriber, confirmation_token=token) + try: + subscriber = NewsletterSubscriber.objects.get(confirmation_token=token) + except NewsletterSubscriber.DoesNotExist: + logger.warning(f"Invalid confirmation token used: {token}") + messages.error(request, "The confirmation link is invalid or has already been used.") + return redirect("newsletter_home") + logger.info(f"Found subscriber with email: {subscriber.email}") try: @@ -1948,10 +1954,6 @@ def newsletter_confirm(request, token): else: messages.info(request, "Your subscription is already confirmed.") - return redirect("newsletter_home") - except NewsletterSubscriber.DoesNotExist: - logger.warning(f"Invalid confirmation token used: {token}") - messages.error(request, "The confirmation link is invalid or has already been used.") return redirect("newsletter_home") except Exception: logger.exception("Unexpected error in newsletter confirmation") @@ -2108,7 +2110,8 @@ def newsletter_resend_confirmation(request): if last_sent: time_since_last = time.time() - last_sent if time_since_last < 300: - time.sleep(max(0, min(2, 2 - (time.time() - start_time)))) + remaining = max(0, 2 - (time.time() - start_time)) + time.sleep(remaining) return JsonResponse( {"success": False, "error": "Please wait at least 5 minutes before requesting another email."} ) @@ -2121,7 +2124,8 @@ def newsletter_resend_confirmation(request): except NewsletterSubscriber.DoesNotExist: # Sleep to maintain consistent timing even if email doesn't exist # This prevents timing attacks - time.sleep(max(0, min(2, 2 - (time.time() - start_time)))) + remaining = max(0, 2 - (time.time() - start_time)) + time.sleep(remaining) logger.info(f"Confirmation resend attempted for non-existent subscription: {email}") return JsonResponse({"success": True}) # Return success even if email doesn't exist From ac3b4276d1580bb2a074aa90e92bdb74afcfc764 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 19:59:58 +0530 Subject: [PATCH 34/39] fix: replace get_object_or_404 in newsletter_unsubscribe - Replace get_object_or_404 with try/except to properly catch NewsletterSubscriber.DoesNotExist (get_object_or_404 raises Http404) - Remove the now-redundant outer except block for DoesNotExist --- website/views/user.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/website/views/user.py b/website/views/user.py index 820ee3fb72..50edc02c53 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -1984,7 +1984,12 @@ def newsletter_unsubscribe(request, token): messages.error(request, "Too many unsubscribe attempts. Please try again later.") return redirect("home") - subscriber = get_object_or_404(NewsletterSubscriber, confirmation_token=token) + try: + subscriber = NewsletterSubscriber.objects.get(confirmation_token=token) + except NewsletterSubscriber.DoesNotExist: + logger.warning(f"Invalid token used for unsubscribe: {token}") + messages.error(request, "Invalid unsubscribe link. Please contact support if you need assistance.") + return redirect("home") # Add token creation time validation for extra security if subscriber.token_created_at: @@ -2024,10 +2029,6 @@ def newsletter_unsubscribe(request, token): else: messages.info(request, "You are already unsubscribed.") - return redirect("home") - except NewsletterSubscriber.DoesNotExist: - logger.warning(f"Invalid token used for unsubscribe: {token}") - messages.error(request, "Invalid unsubscribe link. Please contact support if you need assistance.") return redirect("home") except Exception: logger.exception("Error in newsletter unsubscribe") From 22e2bbbd3be6aa0461c39c16800bb514f4dcab4f Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 20:12:42 +0530 Subject: [PATCH 35/39] fix: use timedelta from datetime module instead of timezone.timedelta - Add timedelta to imports from datetime module - Replace timezone.timedelta(days=90) with timedelta(days=90) - timezone.timedelta doesn't exist; timedelta is a separate class --- website/views/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/views/user.py b/website/views/user.py index 50edc02c53..c855ae43f3 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -5,7 +5,7 @@ import smtplib import time import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from allauth.account.signals import user_signed_up from dateutil import parser as dateutil_parser @@ -1993,7 +1993,7 @@ def newsletter_unsubscribe(request, token): # Add token creation time validation for extra security if subscriber.token_created_at: - max_token_age = timezone.now() - timezone.timedelta(days=90) + max_token_age = timezone.now() - timedelta(days=90) if subscriber.token_created_at < max_token_age: # Token too old - refresh it and show warning subscriber.refresh_token() From 6762977f73b1d31f73f090d51fe2267c6ac3a64e Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 20:21:19 +0530 Subject: [PATCH 36/39] fix: add timing delay to success path to prevent email enumeration The success path for existing subscribers returned immediately, allowing attackers to distinguish email existence by response time. Now all paths (success, not found, rate limited) use consistent 2-second minimum timing. --- website/views/user.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/views/user.py b/website/views/user.py index c855ae43f3..1165c1dd78 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -2130,6 +2130,9 @@ def newsletter_resend_confirmation(request): logger.info(f"Confirmation resend attempted for non-existent subscription: {email}") return JsonResponse({"success": True}) # Return success even if email doesn't exist + # Apply same timing delay for success path to prevent timing-based email enumeration + remaining = max(0, 2 - (time.time() - start_time)) + time.sleep(remaining) return JsonResponse({"success": True}) except Exception: logger.exception("Error in newsletter_resend_confirmation") From 8417ae19666883ba7401152020fbbe67b69250b4 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 22:37:03 +0530 Subject: [PATCH 37/39] fix: resolve migration conflict with upstream - Add 0259_add_search_history.py from upstream - Rename newsletter migration to 0260_newsletter_newslettersubscriber.py - Update dependency to chain after search_history migration --- ...tersubscriber.py => 0260_newsletter_newslettersubscriber.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename website/migrations/{0259_newsletter_newslettersubscriber.py => 0260_newsletter_newslettersubscriber.py} (98%) diff --git a/website/migrations/0259_newsletter_newslettersubscriber.py b/website/migrations/0260_newsletter_newslettersubscriber.py similarity index 98% rename from website/migrations/0259_newsletter_newslettersubscriber.py rename to website/migrations/0260_newsletter_newslettersubscriber.py index 446c34111c..88134214cc 100644 --- a/website/migrations/0259_newsletter_newslettersubscriber.py +++ b/website/migrations/0260_newsletter_newslettersubscriber.py @@ -11,7 +11,7 @@ class Migration(migrations.Migration): dependencies = [ - ("website", "0258_add_slackchannel_model"), + ("website", "0259_add_search_history"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] From d7d33864a4f744f1e1e93179d465e87edb1c2631 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Wed, 3 Dec 2025 22:58:11 +0530 Subject: [PATCH 38/39] fix: ensure Newsletter slug is always unique and non-empty - Remove blank=True from slug field to require a value - Implement unique slug auto-generation in save() method - Handle edge cases: empty slugify result uses UUID-based slug - Append counter suffix for duplicate titles to ensure uniqueness --- .../0260_newsletter_newslettersubscriber.py | 2 +- website/models.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/website/migrations/0260_newsletter_newslettersubscriber.py b/website/migrations/0260_newsletter_newslettersubscriber.py index 88134214cc..85959a28e7 100644 --- a/website/migrations/0260_newsletter_newslettersubscriber.py +++ b/website/migrations/0260_newsletter_newslettersubscriber.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("title", models.CharField(max_length=255)), - ("slug", models.SlugField(blank=True, unique=True)), + ("slug", models.SlugField(unique=True)), ("content", mdeditor.fields.MDTextField(help_text="Write newsletter content in Markdown format")), ("featured_image", models.ImageField(blank=True, null=True, upload_to="newsletter_images")), ("created_at", models.DateTimeField(auto_now_add=True)), diff --git a/website/models.py b/website/models.py index 39e5a480fb..be45a3facb 100644 --- a/website/models.py +++ b/website/models.py @@ -3520,7 +3520,7 @@ class Newsletter(models.Model): ) title = models.CharField(max_length=255) - slug = models.SlugField(unique=True, blank=True) + slug = models.SlugField(unique=True, blank=False) content = MDTextField(help_text="Write newsletter content in Markdown format") featured_image = models.ImageField(upload_to="newsletter_images", blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) @@ -3550,7 +3550,19 @@ def __str__(self): def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(self.title) + # Generate base slug from title + base_slug = slugify(self.title) + if not base_slug: + # If title doesn't produce a valid slug, use a UUID-based slug + base_slug = f"newsletter-{uuid.uuid4().hex[:8]}" + + # Ensure uniqueness by appending a counter if needed + slug = base_slug + counter = 1 + while Newsletter.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug if self.status == "published" and not self.published_at: self.published_at = timezone.now() From 14b29bfeae3a7103105aa0c45b2732b859461b98 Mon Sep 17 00:00:00 2001 From: Dhairya Raniwal Date: Thu, 4 Dec 2025 00:18:37 +0530 Subject: [PATCH 39/39] fix: use atomic QuerySet.update() in IP middleware to prevent TransactionManagementError Apply fix from PR #5163 to resolve selenium test failures: - Refactor increment_block_count() to use atomic QuerySet.update() with F() - Refactor _record_ip() to use atomic updates with Case/When for MAX_COUNT - Add transaction.get_rollback() guards to skip DB ops in broken transactions - Improve duplicate cleanup logic for IP records - Add proper error logging for exceptions --- blt/middleware/ip_restrict.py | 94 +++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/blt/middleware/ip_restrict.py b/blt/middleware/ip_restrict.py index 39060c96a1..78b5e63702 100644 --- a/blt/middleware/ip_restrict.py +++ b/blt/middleware/ip_restrict.py @@ -105,30 +105,31 @@ def increment_block_count(self, ip=None, network=None, user_agent=None): """ Increment the block count for a specific IP, network, or user agent in the Blocked model. """ - with transaction.atomic(): - if ip: - blocked_entry = Blocked.objects.select_for_update().filter(address=ip).first() - elif network: - blocked_entry = Blocked.objects.select_for_update().filter(ip_network=network).first() - elif user_agent: - # Correct lookup: find if any user_agent_string is a substring of the user_agent - blocked_entry = ( - Blocked.objects.select_for_update() - .filter( - user_agent_string__in=[ - agent - for agent in Blocked.objects.values_list("user_agent_string", flat=True) - if agent is not None and user_agent is not None and agent.lower() in user_agent.lower() - ] - ) - .first() - ) - else: - return # Nothing to increment - - if blocked_entry: - blocked_entry.count = models.F("count") + 1 - blocked_entry.save(update_fields=["count"]) + try: + with transaction.atomic(): + # Check if we're in a broken transaction + if transaction.get_rollback(): + logger.warning("Skipping block count increment - transaction marked for rollback") + return + + # Use atomic QuerySet.update() with F() instead of save() + if ip: + Blocked.objects.filter(address=ip).update(count=models.F("count") + 1) + elif network: + Blocked.objects.filter(ip_network=network).update(count=models.F("count") + 1) + elif user_agent: + # Find matching user agents and update them + matching_agents = [ + agent + for agent in Blocked.objects.values_list("user_agent_string", flat=True) + if agent is not None and user_agent is not None and agent.lower() in user_agent.lower() + ] + if matching_agents: + Blocked.objects.filter(user_agent_string__in=matching_agents).update( + count=models.F("count") + 1 + ) + except Exception as e: + logger.error(f"Error incrementing block count: {str(e)}", exc_info=True) async def record_ip_async(self, ip, agent, path): """ @@ -145,27 +146,32 @@ def _record_ip(self, ip, agent, path): """ try: with transaction.atomic(): - # create unique entry for every unique (ip,path) tuple - # if this tuple already exists, we just increment the count. - ip_records = IP.objects.select_for_update().filter(address=ip, path=path) - if ip_records.exists(): - ip_record = ip_records.first() - - # Calculate the new count and ensure it doesn't exceed the MAX_COUNT - new_count = ip_record.count + 1 - if new_count > MAX_COUNT: - new_count = MAX_COUNT - - ip_record.agent = agent - ip_record.count = new_count - if ip_record.pk: - ip_record.save(update_fields=["agent", "count"]) - - # Clean up any duplicate records that may exist from before locking was implemented - ip_records.exclude(pk=ip_record.pk).delete() - else: - # If no record exists, create a new one + # Check if we're in a broken transaction + if transaction.get_rollback(): + logger.warning(f"Skipping IP recording for {ip} - transaction marked for rollback") + return + + # Try to update existing record using atomic QuerySet.update() with F() + updated = IP.objects.filter(address=ip, path=path).update( + agent=agent, + count=models.Case( + models.When(count__lt=MAX_COUNT, then=models.F("count") + 1), + default=models.Value(MAX_COUNT), + output_field=models.BigIntegerField(), + ), + ) + + # If no record was updated, create a new one + if updated == 0: IP.objects.create(address=ip, agent=agent, count=1, path=path) + + # Clean up any duplicate records (should be rare) + # Use a separate query to avoid issues with the atomic block + duplicates = IP.objects.filter(address=ip, path=path).order_by("created")[1:] + if duplicates.exists(): + duplicate_ids = list(duplicates.values_list("id", flat=True)) + IP.objects.filter(id__in=duplicate_ids).delete() + except Exception as e: # Log the error but don't let it break the request logger.error(f"Error recording IP {ip}: {str(e)}", exc_info=True)