diff --git a/src/apps/core/views.py b/src/apps/core/views.py index 6b8cb5b..f351f96 100644 --- a/src/apps/core/views.py +++ b/src/apps/core/views.py @@ -11,6 +11,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.core.cache import cache from django.core.signing import BadSignature, SignatureExpired, TimestampSigner from django.db import models from django.http import HttpRequest, HttpResponse, JsonResponse @@ -543,40 +544,70 @@ class BlogFeedView(View): FEED_URL = "https://acoruss.substack.com/feed" CACHE_TIMEOUT = 60 * 15 # 15 minutes - # Use a browser-like User-Agent to avoid 403 from Substack - USER_AGENT = "Mozilla/5.0 (compatible; AcorussFeedBot/1.0; +https://acoruss.com)" + STALE_CACHE_TIMEOUT = 60 * 60 * 24 # 24 hours + FEED_CACHE_KEY = "core:blog_feed:posts" + FEED_STALE_CACHE_KEY = "core:blog_feed:posts:stale" + # Use a normal browser-like User-Agent to reduce bot blocking. + USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ) + + def _parse_posts(self, body: bytes) -> list[dict[str, str]]: + root = ET.fromstring(body) # noqa: S314 + + posts = [] + for item in root.findall(".//item")[:6]: + title = item.findtext("title", "") + link = item.findtext("link", "") + pub_date = item.findtext("pubDate", "") + description = item.findtext("description", "") + # Strip HTML tags and decode entities for summary. + summary = unescape(re_sub(r"<[^>]+>", "", description))[:200] + + posts.append( + { + "title": title, + "link": link, + "published": pub_date, + "summary": summary, + } + ) + + return posts async def get(self, request: HttpRequest) -> JsonResponse: import asyncio from urllib.request import Request, urlopen + fresh_cached_posts = cache.get(self.FEED_CACHE_KEY) + if fresh_cached_posts: + return JsonResponse(fresh_cached_posts, safe=False) + try: loop = asyncio.get_event_loop() - req = Request(self.FEED_URL, headers={"User-Agent": self.USER_AGENT}) # noqa: S310 + req = Request( # noqa: S310 + self.FEED_URL, + headers={ + "User-Agent": self.USER_AGENT, + "Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Referer": "https://acoruss.com/", + }, + ) body = await loop.run_in_executor(None, lambda: urlopen(req, timeout=10).read()) # noqa: S310 - root = ET.fromstring(body) # noqa: S314 - - posts = [] - for item in root.findall(".//item")[:6]: - title = item.findtext("title", "") - link = item.findtext("link", "") - pub_date = item.findtext("pubDate", "") - description = item.findtext("description", "") - # Strip HTML tags and decode entities for summary - summary = unescape(re_sub(r"<[^>]+>", "", description))[:200] - - posts.append( - { - "title": title, - "link": link, - "published": pub_date, - "summary": summary, - } - ) + posts = self._parse_posts(body) + + cache.set(self.FEED_CACHE_KEY, posts, self.CACHE_TIMEOUT) + cache.set(self.FEED_STALE_CACHE_KEY, posts, self.STALE_CACHE_TIMEOUT) return JsonResponse(posts, safe=False) except Exception: logger.exception("Failed to fetch blog feed") + stale_posts = cache.get(self.FEED_STALE_CACHE_KEY, []) + if stale_posts: + return JsonResponse(stale_posts, safe=False) return JsonResponse([], safe=False) diff --git a/src/config/settings/prod.py b/src/config/settings/prod.py index d1d3bf3..2c6907a 100644 --- a/src/config/settings/prod.py +++ b/src/config/settings/prod.py @@ -22,5 +22,22 @@ SECURE_HSTS_PRELOAD = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +# Logging in production: keep scanner noise from flooding logs while preserving errors. +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "loggers": { + "django.request": { + "level": env("DJANGO_REQUEST_LOG_LEVEL", default="ERROR"), + }, + "django.security.csrf": { + "level": env("DJANGO_CSRF_LOG_LEVEL", default="ERROR"), + }, + "security.probes": { + "level": env("PROBE_LOG_LEVEL", default="WARNING"), + }, + }, +} + # Email via Acoruss Mailer (direct API integration in apps.core.mailer) # ACORUSS_MAILER_KEY is read from env in base.py diff --git a/src/static/css/main.css b/src/static/css/main.css index 96c467f..bf83229 100644 --- a/src/static/css/main.css +++ b/src/static/css/main.css @@ -5023,6 +5023,9 @@ html:has(.drawer-toggle:checked) { .min-w-\[180px\] { min-width: 180px; } +.min-w-\[4rem\] { + min-width: 4rem; +} .max-w-2xl { max-width: 42rem; } @@ -5373,6 +5376,9 @@ html:has(.drawer-toggle:checked) { .bg-base-300\/40 { background-color: var(--fallback-b3,oklch(var(--b3)/0.4)); } +.bg-base-300\/50 { + background-color: var(--fallback-b3,oklch(var(--b3)/0.5)); +} .bg-base-content\/20 { background-color: var(--fallback-bc,oklch(var(--bc)/0.2)); } @@ -5514,6 +5520,9 @@ html:has(.drawer-toggle:checked) { .\!p-3 { padding: 0.75rem !important; } +.p-1 { + padding: 0.25rem; +} .p-1\.5 { padding: 0.375rem; } @@ -5634,9 +5643,15 @@ html:has(.drawer-toggle:checked) { .pb-1 { padding-bottom: 0.25rem; } +.pb-10 { + padding-bottom: 2.5rem; +} .pb-12 { padding-bottom: 3rem; } +.pb-16 { + padding-bottom: 4rem; +} .pb-20 { padding-bottom: 5rem; } @@ -6310,10 +6325,18 @@ a, button { padding-bottom: 7rem; } + .sm\:pb-20 { + padding-bottom: 5rem; + } + .sm\:pb-28 { padding-bottom: 7rem; } + .sm\:pt-12 { + padding-top: 3rem; + } + .sm\:pt-4 { padding-top: 1rem; } diff --git a/tests/test_views.py b/tests/test_views.py index e12944c..f14ab56 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -2,12 +2,15 @@ import json from decimal import Decimal -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest +from django.core.cache import cache from django.test import Client from django.urls import reverse +from apps.core.views import BlogFeedView + @pytest.mark.django_db class TestPublicViews: @@ -132,3 +135,59 @@ def test_does_not_accept_query_params_injection(self, client: Client) -> None: response = client.get(self.URL + "?currency=EUR&callback=alert(1)") # Should either return 200 (ignored params) or the normal response assert response.status_code in (200, 503) + + +@pytest.mark.django_db +class TestBlogFeedEndpoint: + """Test the /api/blog-feed/ endpoint.""" + + URL = reverse("core:blog_feed") + + @pytest.mark.asyncio + @patch("urllib.request.urlopen") + async def test_returns_parsed_posts_on_success(self, mock_urlopen, async_client) -> None: + """Test that RSS items are parsed and returned as JSON.""" + cache.clear() + rss = b""" + + + Latest Acoruss Update + https://acoruss.substack.com/p/latest + Mon, 18 May 2026 10:00:00 GMT + New platform launch updates.

]]>
+
+
""" + + mock_response = Mock() + mock_response.read.return_value = rss + mock_urlopen.return_value = mock_response + + response = await async_client.get(self.URL) + + assert response.status_code == 200 + data = json.loads(response.content) + assert len(data) == 1 + assert data[0]["title"] == "Latest Acoruss Update" + assert data[0]["link"] == "https://acoruss.substack.com/p/latest" + assert "New platform launch updates." in data[0]["summary"] + + @pytest.mark.asyncio + @patch("urllib.request.urlopen", side_effect=Exception("403")) + async def test_uses_stale_cache_when_upstream_fails(self, _mock_urlopen, async_client) -> None: + """Test stale cache fallback is returned when feed fetch fails.""" + cache.clear() + stale_posts = [ + { + "title": "Cached Post", + "link": "https://acoruss.substack.com/p/cached", + "published": "Sun, 17 May 2026 10:00:00 GMT", + "summary": "Cached summary", + } + ] + cache.set(BlogFeedView.FEED_STALE_CACHE_KEY, stale_posts, BlogFeedView.STALE_CACHE_TIMEOUT) + + response = await async_client.get(self.URL) + + assert response.status_code == 200 + data = json.loads(response.content) + assert data == stale_posts